掃碼登陸原理及實現

因爲掃碼登陸比帳號密碼登陸更方便、快捷、靈活,在實際使用中更受到用戶的歡迎。php

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

掃碼登陸的原理

總體流程

爲方便理解,我簡單畫了一個 UML 時序圖,用以描述掃碼登陸的大體流程!html

總結下核心流程:前端

  1. 請求業務服務器獲取用以登陸的二維碼和 UUID。jquery

  2. 經過 websocket 鏈接 socket 服務器,並定時(時間間隔依據服務器配置時間調整)發送心跳保持鏈接。nginx

  3. 用戶經過 APP 掃描二維碼,發送請求到業務服務器處理登陸。根據 UUID 設置登陸結果。git

  4. socket 服務器經過監聽獲取登陸結果,創建 session 數據,根據 UUID 推送登陸數據到用戶瀏覽器。github

  5. 用戶登陸成功,服務器主動將該 socker 鏈接從鏈接池中剔除,該二維碼失效。web

關於客戶端標識

也就是 UUID,這是貫穿整個流程的紐帶,一個閉環登陸過程,每一步業務處理都是圍繞該次的 UUD 進行處理的。UUID 的生成有根據 session_id 的也有根據客戶端 ip 地址的。我的仍是建議每一個二維碼都有單獨的 UUID,適用場景更廣一些!ajax

關於前端和服務器通信

前端確定是要和服務器保持一直通信的,用以獲取登陸結果和二維碼狀態。看了下網上的一些實現方案,基本各個方案都有用的:輪詢、長輪詢、長連接、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

相關文章
相關標籤/搜索