扫码登录原理及实现 | BNDong
0%

扫码登录原理及实现

由于扫码登录比账号密码登录更方便、快捷、灵活,在实际使用中更受到用户的欢迎。

本文主要介绍了扫码登录的原理及整体流程,包含了二维码的生成/获取、过期失效的处理、登录状态的监听。

扫码登录的原理

整体流程

为方便理解,我简单画了一个 UML 时序图,用以描述扫码登录的大致流程!

总结下核心流程:

1) 请求业务服务器获取用以登录的二维码和 UUID。

2) 通过 websocket 连接 socket 服务器,并定时(时间间隔依据服务器配置时间调整)发送心跳保持连接。

3) 用户通过 APP 扫描二维码,发送请求到业务服务器处理登录。根据 UUID 设置登录结果。

4) socket 服务器通过监听获取登录结果,建立 session 数据,根据 UUID 推送登录数据到用户浏览器。

5) 用户登录成功,服务器主动将该 socker 连接从连接池中剔除,该二维码失效。

关于客户端标识

也就是 UUID,这是贯穿整个流程的纽带,一个闭环登录过程,每一步业务处理都是围绕该次的 UUD 进行处理的。UUID 的生成有根据 session_id 的也有根据客户端 ip 地址的。个人还是建议每个二维码都有单独的 UUID,适用场景更广一些!

关于前端和服务器通讯

前端肯定是要和服务器保持一直通讯的,用以获取登录结果和二维码状态。看了下网上的一些实现方案,基本各个方案都有用的:轮询、长轮询、长链接、websocket。也不能肯定的说哪个方案好哪个方案不好,只能说哪个方案更适用于当前应用场景。个人比较建议使用长轮询、websocket 这种比较节省服务器性能的方案。

关于安全性

扫码登录的好处显而易见,一是人性化,再就是防止密码泄漏。但是新方式的接入,往往也伴随着新的风险。所以,很有必要再整体过程中加入适当的安全机制。例如:

  • 强制 HTTPS 协议
  • 短期令牌
  • 数据签名
  • 数据加密

扫码登录的过程演示

代码实现和源码后面会给出。

开启 Socket 服务器

访问登录页面

可以看到用户请求的二维码资源,并获取到了 qid

获取二维码时候会建立相应缓存,并设置过期时间:

之后会连接 socket 服务器,定时发送心跳。

此时 socket 服务器会有相应连接日志输出:

用户使用 APP 扫码并授权

服务器验证并处理登录,创建 session,建立对应的缓存:

Socket 服务器读取到缓存,开始推送信息,并关闭剔除连接:

前端获取信息,处理登录:

扫码登录的实现

注意:本 Demo 只是个人学习测试,所以并未做太多安全机制!

Socket 代理服务器

使用 Nginx 作为代理 socke 服务器。可使用域名,方便做负载均衡。本次测试域名:loc.websocket.net

websocker.conf

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
server {
listen 80;
server_name loc.websocket.net;
root /www/websocket;
index index.php index.html index.htm;
#charset koi8-r;

access_log /dev/null;
#access_log /var/log/nginx/nginx.localhost.access.log main;
error_log /var/log/nginx/nginx.websocket.error.log warn;

#error_page 404 /404.html;

# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}

location / {
proxy_pass http://php-cli:8095/;
proxy_http_version 1.1;
proxy_connect_timeout 4s;
proxy_read_timeout 60s;
proxy_send_timeout 12s;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}

Socket 服务器

使用 PHP 构建的 socket 服务器。实际项目中大家可以考虑使用第三方应用,稳定性更好一些!

QRServer.php

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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
<?php

require_once dirname(dirname(__FILE__)) . '/Config.php';
require_once dirname(dirname(__FILE__)) . '/lib/RedisUtile.php';
require_once dirname(dirname(__FILE__)) . '/lib/Common.php';

/**
* 扫码登陆服务端
* Class QRServer
* @author BNDong
*/
class QRServer {

private $_sock;
private $_redis;
private $_clients = array();

/**
* socketServer constructor.
*/
public function __construct()
{
// 设置 timeout
set_time_limit(0);

// 创建一个套接字(通讯节点)
$this->_sock = socket_create(AF_INET, SOCK_STREAM, SOL_TCP) or die("Could not create socket" . PHP_EOL);
socket_set_option($this->_sock, SOL_SOCKET, SO_REUSEADDR, 1);

// 绑定地址
socket_bind($this->_sock, \Config::QRSERVER_HOST, \Config::QRSERVER_PROT) or die("Could not bind to socket" . PHP_EOL);

// 监听套接字上的连接
socket_listen($this->_sock, 4) or die("Could not set up socket listener" . PHP_EOL);

$this->_redis = \lib\RedisUtile::getInstance();
}

/**
* 启动服务
*/
public function run()
{
$this->_clients = array();
$this->_clients[uniqid()] = $this->_sock;

while (true){
$changes = $this->_clients;
$write = NULL;
$except = NULL;
socket_select($changes, $write, $except, NULL);
foreach ($changes as $key => $_sock) {

if($this->_sock == $_sock){ // 判断是不是新接入的 socket

if(($newClient = socket_accept($_sock)) === false){
die('failed to accept socket: '.socket_strerror($_sock)."\n");
}

$buffer = trim(socket_read($newClient, 1024)); // 读取请求
$response = $this->handShake($buffer);
socket_write($newClient, $response, strlen($response)); // 发送响应
socket_getpeername($newClient, $ip); // 获取 ip 地址

$qid = $this->getHandQid($buffer);
$this->log("new clinet: ". $qid);

if ($qid) { // 验证是否存在 qid
if (isset($this->_clients[$qid])) $this->close($qid, $this->_clients[$qid]);
$this->_clients[$qid] = $newClient;
} else {
$this->close($qid, $newClient);
}

} else {

// 判断二维码是否过期
if ($this->_redis->exists(\lib\Common::getQidKey($key))) {

$loginKey = \lib\Common::getQidLoginKey($key);
if ($this->_redis->exists($loginKey)) { // 判断用户是否扫码
$this->send($key, $this->_redis->get($loginKey));
$this->close($key, $_sock);
}

$res = socket_recv($_sock, $buffer, 2048, 0);
if (false === $res) {
$this->close($key, $_sock);
} else {
$res && $this->log("{$key} clinet msg: " . $this->message($buffer));
}
} else {
$this->close($key, $this->_clients[$key]);
}

}
}
sleep(1);
}
}

/**
* 构建响应
* @param string $buf
* @return string
*/
private function handShake($buf){
$buf = substr($buf,strpos($buf,'Sec-WebSocket-Key:') + 18);
$key = trim(substr($buf, 0, strpos($buf,"\r\n")));
$newKey = base64_encode(sha1($key."258EAFA5-E914-47DA-95CA-C5AB0DC85B11",true));
$newMessage = "HTTP/1.1 101 Switching Protocols\r\n";
$newMessage .= "Upgrade: websocket\r\n";
$newMessage .= "Sec-WebSocket-Version: 13\r\n";
$newMessage .= "Connection: Upgrade\r\n";
$newMessage .= "Sec-WebSocket-Accept: " . $newKey . "\r\n\r\n";
return $newMessage;
}

/**
* 获取 qid
* @param string $buf
* @return mixed|string
*/
private function getHandQid($buf) {
preg_match("/^[\s\n]?GET\s+\/\?qid\=([a-z0-9]+)\s+HTTP.*/", $buf, $matches);
$qid = isset($matches[1]) ? $matches[1] : '';
return $qid;
}

/**
* 编译发送数据
* @param string $s
* @return string
*/
private function frame($s) {
$a = str_split($s, 125);
if (count($a) == 1) {
return "\x81" . chr(strlen($a[0])) . $a[0];
}
$ns = "";
foreach ($a as $o) {
$ns .= "\x81" . chr(strlen($o)) . $o;
}
return $ns;
}

/**
* 解析接收数据
* @param resource $buffer
* @return null|string
*/
private function message($buffer){
$masks = $data = $decoded = null;
$len = ord($buffer[1]) & 127;
if ($len === 126) {
$masks = substr($buffer, 4, 4);
$data = substr($buffer, 8);
} else if ($len === 127) {
$masks = substr($buffer, 10, 4);
$data = substr($buffer, 14);
} else {
$masks = substr($buffer, 2, 4);
$data = substr($buffer, 6);
}
for ($index = 0; $index < strlen($data); $index++) {
$decoded .= $data[$index] ^ $masks[$index % 4];
}
return $decoded;
}

/**
* 发送消息
* @param string $qid
* @param string $msg
*/
private function send($qid, $msg)
{
$frameMsg = $this->frame($msg);
socket_write($this->_clients[$qid], $frameMsg, strlen($frameMsg));
$this->log("{$qid} clinet send: " . $msg);
}

/**
* 关闭 socket
* @param string $qid
* @param resource $socket
*/
private function close($qid, $socket)
{
socket_close($socket);
if (array_key_exists($qid, $this->_clients)) unset($this->_clients[$qid]);
$this->_redis->del(\lib\Common::getQidKey($qid));
$this->_redis->del(\lib\Common::getQidLoginKey($qid));
$this->log("{$qid} clinet close");
}

/**
* 日志记录
* @param string $msg
*/
private function log($msg)
{
echo '['. date('Y-m-d H:i:s') .'] ' . $msg . "\n";
}
}

$server = new QRServer();
$server->run();

登录页面

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
130
131
132
133
134
135
136
137
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>扫码登录 - 测试页面</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="./public/css/main.css">
</head>
<body translate="no">

<div class='box'>
<div class='box-form'>
<div class='box-login-tab'></div>
<div class='box-login-title'>
<div class='i i-login'></div><h2>登录</h2>
</div>
<div class='box-login'>
<div class='fieldset-body' id='login_form'>
<button onclick="openLoginInfo();" class='b b-form i i-more' title='Mais Informações'></button>
<p class='field'>
<label for='user'>用户账户</label>
<input type='text' id='user' name='user' title='Username' placeholder="请输入用户账户/邮箱地址" />
</p>
<p class='field'>
<label for='pass'>用户密码</label>
<input type='password' id='pass' name='pass' title='Password' placeholder="情输入账户密码" />
</p>
<label class='checkbox'>
<input type='checkbox' value='TRUE' title='Keep me Signed in' /> 记住我
</label>
<input type='submit' id='do_login' value='登录' title='登录' />
</div>
</div>
</div>
<div class='box-info'>
<p><button onclick="closeLoginInfo();" class='b b-info i i-left' title='Back to Sign In'></button><h3>扫码登录</h3>
</p>
<div class='line-wh'></div>
<div style="position: relative;">
<input type="hidden" id="qid" value="">
<div id="qrcode-exp">二维码已失效<br>点击重新获取</div>
<img id="qrcode" src="" />
</div>
</div>
</div>
<script src='./public/js/jquery.min.js'></script>
<script src='./public/js/modernizr.min.js'></script>
<script id="rendered-js">
$(document).ready(function () {

restQRCode();
openLoginInfo();
$('#qrcode-exp').click(function () {
restQRCode();
$(this).hide();
});
});

/**
* 打开二维码
*/
function openLoginInfo() {
$(document).ready(function () {
$('.b-form').css("opacity", "0.01");
$('.box-form').css("left", "-100px");
$('.box-info').css("right", "-100px");
});
}

/**
* 关闭二维码
*/
function closeLoginInfo() {
$(document).ready(function () {
$('.b-form').css("opacity", "1");
$('.box-form').css("left", "0px");
$('.box-info').css("right", "-5px");
});
}

/**
* 刷新二维码
*/
var ws, wsTid = null;
function restQRCode() {

$.ajax({
url: 'http://localhost/qrcode/code.php',
type:'post',
dataType: "json",
async: false,
success:function (result) {
$('#qrcode').attr('src', result.img);
$('#qid').val(result.qid);
}
});

if ("WebSocket" in window) {
if (typeof ws != 'undefined'){
ws.close();
null != wsTid && window.clearInterval(wsTid);
}

ws = new WebSocket("ws://loc.websocket.net?qid=" + $('#qid').val());

ws.onopen = function() {
console.log('websocket 已连接上!');
};

ws.onmessage = function(e) {
// todo: 本函数做登录处理,登录判断,创建缓存信息!
console.log(e.data);
var result = JSON.parse(e.data);
console.log(result);
alert('登录成功:' + result.name);
};

ws.onclose = function() {
console.log('websocket 连接已关闭!');
$('#qrcode-exp').show();
null != wsTid && window.clearInterval(wsTid);
};

// 发送心跳
wsTid = window.setInterval( function () {
if (typeof ws != 'undefined') ws.send('1');
}, 50000 );

} else {

// todo: 不支持 WebSocket 的,可以使用 js 轮询处理,这里不作该功能实现!
alert('您的浏览器不支持 WebSocket!');
}
}
</script>
</body>
</html>

登录处理

测试使用,模拟登录处理,未做安全认证!!

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
<?php

require_once dirname(__FILE__) . '/lib/RedisUtile.php';
require_once dirname(__FILE__) . '/lib/Common.php';

/**
* ------- 登录逻辑模拟 --------
* 请根据实际编写登录逻辑并处理安全验证
*/

$qid = $_GET['qid'];
$uid = $_GET['uid'];

$data = array();
switch ($uid)
{
case '1':
$data['uid'] = 1;
$data['name'] = '张三';
break;

case '2':
$data['uid'] = 2;
$data['name'] = '李四';
break;
}

$data = json_encode($data);
$redis = \lib\RedisUtile::getInstance();
$redis->setex(\lib\Common::getQidLoginKey($qid), 1800, $data);

完整 Demo 源码:BNDong/demo/scanCodeLogin

↓赏一个鸡腿... 要不,半个也行↓