分享PHP掃碼登入原理及實現方法

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

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

掃碼登入的原理

整體流程

為方便理解,我簡單畫了一個 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  = libRedisUtile::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(libCommon::getQidKey($key))) {

                        $loginKey = libCommon::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,"rn")));
        $newKey = base64_encode(sha1($key."258EAFA5-E914-47DA-95CA-C5AB0DC85B11",true));
        $newMessage = "HTTP/1.1 101 Switching Protocolsrn";
        $newMessage .= "Upgrade: websocketrn";
        $newMessage .= "Sec-WebSocket-Version: 13rn";
        $newMessage .= "Connection: Upgradern";
        $newMessage .= "Sec-WebSocket-Accept: " . $newKey . "rnrn";        return $newMessage;
    }    /**
     * 獲取 qid
     * @param string $buf
     * @return mixed|string     */
    private function getHandQid($buf) {
        preg_match("/^[sn]?GETs+/?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(libCommon::getQidKey($qid));
        $this->_redis->del(libCommon::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">

<p class='box'>
    <p class='box-form'>
        <p class='box-login-tab'></p>
        <p class='box-login-title'>
            <p class='i i-login'></p><h2>登入</h2>
        </p>
        <p class='box-login'>
            <p 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='登入' />
            </p>
        </p>
    </p>
    <p class='box-info'>
        <p><button onclick="closeLoginInfo();" class='b b-info i i-left' title='Back to Sign In'></button><h3>掃碼登入</h3>
        </p>
        <p class='line-wh'></p>
        <p style="position: relative;">
            <input type="hidden" id="qid" value="">
            <p id="qrcode-exp">二維條碼已失效<br>點選重新獲取</p>
            <img id="qrcode" src="" />
        </p>
    </p>
</p>
<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 = libRedisUtile::getInstance();
$redis->setex(libCommon::getQidLoginKey($qid), 1800, $data);

更多相關知識,請存取PHP中文網

以上就是分享PHP掃碼登入原理及實現方法的詳細內容,更多請關注TW511.COM其它相關文章!