1. 程式人生 > 實用技巧 >掃碼登入的原理和實現

掃碼登入的原理和實現

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

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

掃碼登入的原理

整體流程

為方便理解,我簡單畫了一個 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許可協議。轉載請註明出處!
聲援博主:如果您覺得文章對您有幫助,可以點選文章右下角推薦】一下。您的鼓勵是博主的最大動力!