由於掃碼登錄比賬號密碼登錄更方便、快捷、靈活,在實際使用中更受到用戶的歡迎。
本文主要介紹了掃碼登錄的原理及整體流程,包含了二維碼的生成/獲取、過期失效的處理、登錄狀態的監聽。
掃碼登錄的原理
整體流程
為方便理解,我簡單畫了一個 UML 時序圖,用以描述掃碼登錄的大致流程!
總結下核心流程:
-
請求業務服務器獲取用以登錄的二維碼和 UUID。
-
通過 websocket 連接 socket 服務器,並定時(時間間隔依據服務器配置時間調整)發送心跳保持連接。
-
用戶通過 APP 掃描二維碼,發送請求到業務服務器處理登錄。根據 UUID 設置登錄結果。
-
socket 服務器通過監聽獲取登錄結果,建立 session 數據,根據 UUID 推送登錄數據到用戶瀏覽器。
-
用戶登錄成功,服務器主動將該 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
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
<?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();
登錄頁面
<!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>
登錄處理
測試使用,模擬登錄處理,未做安全認證!!
<?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