掃碼登錄的原理和實現


由於掃碼登錄比賬號密碼登錄更方便、快捷、靈活,在實際使用中更受到用戶的歡迎。

本文主要介紹了掃碼登錄的原理及整體流程,包含了二維碼的生成/獲取、過期失效的處理、登錄狀態的監聽。

掃碼登錄的原理

整體流程

為方便理解,我簡單畫了一個 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

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

 

__EOF__

本文作者:BNDong
本文鏈接:https://www.cnblogs.com/bndong/p/12607579.html
關於博主:喜歡讀書、旅行、爬山。評論和私信會在第一時間回復。或者直接私信我。
版權聲明:本博客所有文章除特別聲明外,均采用 BY-NC-SA 許可協議。轉載請注明出處!
聲援博主:如果您覺得文章對您有幫助,可以點擊文章右下角推薦】一下。您的鼓勵是博主的最大動力!

 

 
 
 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM