网络基础12 二维码扫码登录原理

微信网页版为例,学习一下二维码登陆的原理。

[toc]

扫码登陆原理

微信网页版为例,学习一下二维码登陆的原理。

浏览器打开登陆页面

打开页面之后,前端脚本会完成下面几个过程:

(1)请求二维码

浏览器打开页面之后,会首先向服务器发送一个请求,获得二维码,

利用解析二维码工具可以得到二维码的内容https://login.weixin.qq.com/l/Ie00Yc04-A==,可以看出来,实际上这个二维码包含的信息实际上就是这个请求的URL

这个URL后面对应的Ie00Yc04-A==是一个全局唯一ID,它的用处就是用来识别请求登陆的客户端,如何识别后面会讲解

(2)通过轮询建立『长连接』

打开这个页面之后,浏览器会通过堵塞等待的轮询变相的建立了一个长连接,这个轮询是每间隔25秒向服务器发送一个请求<srcipt>的GET请求:

如果25秒之内用户没有扫描这个二维码,这个请求会返回200,结束它的使命。浏览器会再次发送一个请求。这个请求的地址是https://login.wx.qq.com/cgi-bin/mmwebwx-bin/login?loginicon=true&uuid=Ie00Yc04-A==&tip=0&r=-1708735754&_=1577961552943

可以看到,通过这个请求里包含了一个uuid字段,值就是前面提到的全局唯一ID,我们可以认为这个ID就是这个所谓的长连接的ID

查看了一下微信网页版的前端代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
function checkLoginHandler(data) {
/**
* code:
* 200: 成功
* 201:扫描成功,但未点确认
* 408:未扫描
* 400:未知
* 500: login poll srv exception
*
*/
switch (data.code) {
case 200:
loginFactory.newLoginPage(data.redirect_uri).then(function(msg) {
var ret = msg.match(/<ret>(.*)<\/ret>/),
code = msg.match(/<script>(.*)<\/script>/),
skey = msg.match(/<skey>(.*)<\/skey>/),
wxsid = msg.match(/<wxsid>(.*)<\/wxsid>/),
wxuin = msg.match(/<wxuin>(.*)<\/wxuin>/),
passticket = msg.match(/<pass_ticket>(.*)<\/pass_ticket>/),
message = msg.match(/<message>(.*)<\/message>/),
redirecturl = msg.match(/<redirecturl>(.*)<\/redirecturl>/);
if (redirecturl) {
window.location.href = redirecturl[1];
return;
}
if (ret && (ret[1] != '0')) {
alert((message && message[1]) || '登陆失败');
monitorService.report(monitorService.AUTH_FAIL_COUNT, 1);
location.reload();
return;
}
$scope.$emit('newLoginPage', {
Ret: ret && ret[1],
SKey: skey && skey[1],
Sid: wxsid && wxsid[1],
Uin: wxuin && wxuin[1],
Passticket: passticket && passticket[1],
Code: code
});
if (!utilFactory.getCookie('webwx_data_ticket')) {
reportService.report(reportService.ReportType.cookieError, {
text: 'webwx_data_ticket 票据丢失',
cookie: document.cookie
});
}
});
break;
case 201:
$scope.isScan = true;
reportService.report(reportService.ReportType.timing, {
timing: {
scan: Date.now()
}
});
loginFactory.checkLogin($scope.uuid).then(checkLoginHandler, function(data) {
if (!data && window.checkLoginPromise) {
$scope.isBrokenNetwork = true;
}
});
break;
case 408:
loginFactory.checkLogin($scope.uuid).then(checkLoginHandler, function(data) {
if (!data && window.checkLoginPromise) {
$scope.isBrokenNetwork = true;
}
});
break;
case 400:
case 500:
case 0:
// 这里要累计次数
var refreshTimes = utilFactory.getCookie('refreshTimes') || 0;
if (refreshTimes < 5) {
refreshTimes++;
utilFactory.setCookie('refreshTimes', refreshTimes, 0.5);
document.location.reload();
} else {
$scope.isNeedRefresh = true;
}
break;
case 202: // 点击取消
// 1. 关联登录,等待确认,取消
// 2. 扫码之后,等待确认,取消
$scope.isScan = false;
$scope.isAssociationLogin = false;
utilFactory.setCookie('login_frequency', 0, 2);
// 终止轮询
if (window.checkLoginPromise) {
window.checkLoginPromise.abort();
window.checkLoginPromise = null;
}
doQrcodeLogin();
break;
default:
//todo
}
$scope.code = data.code;
$scope.userAvatar = data.userAvatar;
utilFactory.log('get code', data.code);
}

// 检查是否登录
checkLogin: function(uuid, tip) {
var deferred = $q.defer(),
tip = tip || 0;
window.code = 0;
// ie8
window.checkLoginPromise = $.ajax({
url: confFactory.API_login + '?loginicon=true&uuid=' + uuid + '&tip=' + tip + '&r=' + ~new Date(),
dataType: "script",
timeout: 35000
}).done(function() {
var reg = new RegExp('\/' + location.host + '\/')
if (window.redirect_uri && window.redirect_uri.indexOf('/' + location.host + '/') < 0) {
location.href = window.redirect_uri;
return;
}
var data = {
code: window.code,
redirect_uri: window.redirect_uri,
userAvatar: window.userAvatar
};
deferred.resolve(data);
}).fail(function() {
deferred.reject();
console.log('checkLogin fail.....');
});
return deferred.promise;
},

关键的代码是上面两个函数,

这样当超过25s后,服务端对请求脚本的代码返回了200,但是返回的脚本的内容是什么呢?

1
2
3
4
5
{
code: 408
redirect_uri: undefined
userAvatar: undefined
}

其中的code408,然后递归调用checkLogin,可以看出来,25秒的间隔是由服务端确定的

这样在当用户登录后,微信服务器就可以将下一步的动作作为脚本返回给前端?那为什么不直接写在前端脚本中呢?

如果用户长时间没有操作,页面会自动刷新,重新执行上面的过程,保证了二维码不会因为太久没有扫描而过期

(3)其他操作

除此之外,在打开这个页面后,浏览器还会请求很多其他的资源,比如登陆的默认头像、雪碧图等等,这样当用户通过长连接后进行登陆时就不必再去请求这些资源,可以获得立刻反馈的用户体验

手机扫描二维码

当手机微信扫描这个二维码时,相当于用微信客户端,携带用户的用户名等信息,去访问了二维码对应的连接``https://login.weixin.qq.com/l/Ie00Yc04-A==`,这时候,微信登陆服务器获得了两个信息:

  1. 唯一IDIe00Yc04-A==,通过这个ID就可以找到上一步建立的长连接信息,也就找到了要登录的设备
  2. 用户的微信信息,通过这些信息也就找到了要登录的是哪个微信用户

扫描二维码后,客户端就可以通过长连接立刻获得返回的信息:

这时候,长连接返回的信息是:

1
2
3
4
5
{
code: 201
redirect_uri: undefined
userAvatar: "data:img/jpg;base64,/9j/4AAQS..." // 一个 Base64 格式的头像图片,我进行了截取
}

根据上面的代码返回了201,这个时候客户端会进行一些信息的上班,然后继续轮询,等待确认登陆

手机确认登陆

用户在客户端点击确认后,相当于向服务器发送了确认登陆的请求,服务器立刻通过轮询长连接返回信息:

1
2
3
4
5
{
"code": 200,
"redirect_uri": "https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxnewloginpage?ticket=dsfdfsdf@qrticket_0&uuid=IePV21233ddajSA==&lang=zh_CN&scan=15772296510224",
"userAvatar": "data:img/jpg;base64,/9j/4AAQSkZJRgA..."
}

这时,浏览器根据code200执行响应的代码,通过执行loginFactory.newLoginPage来访问上面返回的redirect_uri,根据不同的信息进行登陆或者禁止登陆的操作

以我这次登陆请求为例,不知道什么禁止我使用微信网页版,所以返回来下面的信息:

1
<error><ret>1203</ret><message>为了你的帐号安全,此微信号不能登录网页微信。你可以使用Windows微信或Mac微信在电脑端登录。Windows微信下载地址:https://pc.weixin.qq.com  Mac微信下载地址:https://mac.weixin.qq.com</message></error>

如果是正常的话,就会跳转到对应的页面,开始使用微信网页版,扫码登陆的过程就到此结束。

登陆流程图

以前在头条面试的时候被问过扫码登陆的原理,并没有答的很好,面试结束后大概在网上查了查,学习的简书的这篇文章,它总结的基本流程是没有错,如下图:

但是一些具体的技术细节并没有详细介绍,我发现自己并没有搞清楚,于是花了一会时间,又尝试的去学习了一下,还是亲自动手能够搞得更清楚。

疑惑

我还是有一个疑惑:为什么长连接的请求类型是script呢?

在jQuery的文档里查到的,当dataType设为script时的作用是:

把响应的结果当作JavaScript执行。并将其当作纯文本返回。默认情况下不会通过在URL中附加查询字符串变量_=[TIMESTAMP]进行自动缓存结果,除非设置了cache参数为true。Note: 在远程请求时(不在同一个域下),所有POST请求都将转为GET请求。(因为将使用DOM的script标签来加载)

但是好像在这里没有用到上面的任何一点,只是根据返回的对象的各个属性进行了下一步操作。那直接发送的dataTypejson格式的请求效果不是一样吗?不解。

参考