分享PHP扫码登录原理及实现方法

由于扫码登录比账号密码登录更方便、快捷、灵活,在实际使用中更受到用户的欢迎。

本文主要介绍了扫码登录的原理及整体流程,包含了二维码的生成/获取、过期失效的处理、登录状态的监听。

扫码登录的原理

整体流程

为方便理解,我简单画了一个 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

  1. server {
  2. listen 80;
  3. server_name loc.websocket.net;
  4. root /www/websocket;
  5. index index.php index.html index.htm;
  6. #charset koi8-r;
  7. access_log /dev/null;
  8. #access_log /var/log/nginx/nginx.localhost.access.log main;
  9. error_log /var/log/nginx/nginx.websocket.error.log warn;
  10. #error_page 404 /404.html;
  11. # redirect server error pages to the static page /50x.html
  12. #
  13. error_page 500 502 503 504 /50x.html;
  14. location = /50x.html {
  15. root /usr/share/nginx/html;
  16. }
  17. location / {
  18. proxy_pass http://php-cli:8095/;
  19. proxy_http_version 1.1;
  20. proxy_connect_timeout 4s;
  21. proxy_read_timeout 60s;
  22. proxy_send_timeout 12s;
  23. proxy_set_header Upgrade $http_upgrade;
  24. proxy_set_header Connection $connection_upgrade;
  25. }
  26. }

Socket 服务器

使用 PHP 构建的 socket 服务器。实际项目中大家可以考虑使用第三方应用,稳定性更好一些!

QRServer.php

  1. <?php
  2. require_once dirname(dirname(__FILE__)) . '/Config.php';
  3. require_once dirname(dirname(__FILE__)) . '/lib/RedisUtile.php';
  4. require_once dirname(dirname(__FILE__)) . '/lib/Common.php';/**
  5. * 扫码登陆服务端
  6. * Class QRServer
  7. * @author BNDong */class QRServer { private $_sock; private $_redis; private $_clients = array(); /**
  8. * socketServer constructor. */
  9. public function __construct()
  10. { // 设置 timeout
  11. set_time_limit(0); // 创建一个套接字(通讯节点)
  12. $this->_sock = socket_create(AF_INET, SOCK_STREAM, SOL_TCP) or die("Could not create socket" . PHP_EOL);
  13. socket_set_option($this->_sock, SOL_SOCKET, SO_REUSEADDR, 1); // 绑定地址
  14. socket_bind($this->_sock, \Config::QRSERVER_HOST, \Config::QRSERVER_PROT) or die("Could not bind to socket" . PHP_EOL); // 监听套接字上的连接
  15. socket_listen($this->_sock, 4) or die("Could not set up socket listener" . PHP_EOL);
  16. $this->_redis = \lib\RedisUtile::getInstance();
  17. } /**
  18. * 启动服务 */
  19. public function run()
  20. {
  21. $this->_clients = array();
  22. $this->_clients[uniqid()] = $this->_sock; while (true){
  23. $changes = $this->_clients;
  24. $write = NULL;
  25. $except = NULL;
  26. socket_select($changes, $write, $except, NULL); foreach ($changes as $key => $_sock) { if($this->_sock == $_sock){ // 判断是不是新接入的 socket
  27. if(($newClient = socket_accept($_sock)) === false){
  28. die('failed to accept socket: '.socket_strerror($_sock)."\n");
  29. }
  30. $buffer = trim(socket_read($newClient, 1024)); // 读取请求
  31. $response = $this->handShake($buffer);
  32. socket_write($newClient, $response, strlen($response)); // 发送响应
  33. socket_getpeername($newClient, $ip); // 获取 ip 地址
  34. $qid = $this->getHandQid($buffer);
  35. $this->log("new clinet: ". $qid); if ($qid) { // 验证是否存在 qid
  36. if (isset($this->_clients[$qid])) $this->close($qid, $this->_clients[$qid]);
  37. $this->_clients[$qid] = $newClient;
  38. } else {
  39. $this->close($qid, $newClient);
  40. }
  41. } else { // 判断二维码是否过期
  42. if ($this->_redis->exists(\lib\Common::getQidKey($key))) {
  43. $loginKey = \lib\Common::getQidLoginKey($key); if ($this->_redis->exists($loginKey)) { // 判断用户是否扫码
  44. $this->send($key, $this->_redis->get($loginKey));
  45. $this->close($key, $_sock);
  46. }
  47. $res = socket_recv($_sock, $buffer, 2048, 0); if (false === $res) {
  48. $this->close($key, $_sock);
  49. } else {
  50. $res && $this->log("{$key} clinet msg: " . $this->message($buffer));
  51. }
  52. } else {
  53. $this->close($key, $this->_clients[$key]);
  54. }
  55. }
  56. }
  57. sleep(1);
  58. }
  59. } /**
  60. * 构建响应
  61. * @param string $buf
  62. * @return string */
  63. private function handShake($buf){
  64. $buf = substr($buf,strpos($buf,'Sec-WebSocket-Key:') + 18);
  65. $key = trim(substr($buf, 0, strpos($buf,"\r\n")));
  66. $newKey = base64_encode(sha1($key."258EAFA5-E914-47DA-95CA-C5AB0DC85B11",true));
  67. $newMessage = "HTTP/1.1 101 Switching Protocols\r\n";
  68. $newMessage .= "Upgrade: websocket\r\n";
  69. $newMessage .= "Sec-WebSocket-Version: 13\r\n";
  70. $newMessage .= "Connection: Upgrade\r\n";
  71. $newMessage .= "Sec-WebSocket-Accept: " . $newKey . "\r\n\r\n"; return $newMessage;
  72. } /**
  73. * 获取 qid
  74. * @param string $buf
  75. * @return mixed|string */
  76. private function getHandQid($buf) {
  77. preg_match("/^[\s\n]?GET\s+\/\?qid\=([a-z0-9]+)\s+HTTP.*/", $buf, $matches);
  78. $qid = isset($matches[1]) ? $matches[1] : ''; return $qid;
  79. } /**
  80. * 编译发送数据
  81. * @param string $s
  82. * @return string */
  83. private function frame($s) {
  84. $a = str_split($s, 125); if (count($a) == 1) { return "\x81" . chr(strlen($a[0])) . $a[0];
  85. }
  86. $ns = ""; foreach ($a as $o) {
  87. $ns .= "\x81" . chr(strlen($o)) . $o;
  88. } return $ns;
  89. } /**
  90. * 解析接收数据
  91. * @param resource $buffer
  92. * @return null|string */
  93. private function message($buffer){
  94. $masks = $data = $decoded = null;
  95. $len = ord($buffer[1]) & 127; if ($len === 126) {
  96. $masks = substr($buffer, 4, 4);
  97. $data = substr($buffer, 8);
  98. } else if ($len === 127) {
  99. $masks = substr($buffer, 10, 4);
  100. $data = substr($buffer, 14);
  101. } else {
  102. $masks = substr($buffer, 2, 4);
  103. $data = substr($buffer, 6);
  104. } for ($index = 0; $index < strlen($data); $index++) {
  105. $decoded .= $data[$index] ^ $masks[$index % 4];
  106. } return $decoded;
  107. } /**
  108. * 发送消息
  109. * @param string $qid
  110. * @param string $msg */
  111. private function send($qid, $msg)
  112. {
  113. $frameMsg = $this->frame($msg);
  114. socket_write($this->_clients[$qid], $frameMsg, strlen($frameMsg));
  115. $this->log("{$qid} clinet send: " . $msg);
  116. } /**
  117. * 关闭 socket
  118. * @param string $qid
  119. * @param resource $socket */
  120. private function close($qid, $socket)
  121. {
  122. socket_close($socket); if (array_key_exists($qid, $this->_clients)) unset($this->_clients[$qid]);
  123. $this->_redis->del(\lib\Common::getQidKey($qid));
  124. $this->_redis->del(\lib\Common::getQidLoginKey($qid));
  125. $this->log("{$qid} clinet close");
  126. } /**
  127. * 日志记录
  128. * @param string $msg */
  129. private function log($msg)
  130. {
  131. echo '['. date('Y-m-d H:i:s') .'] ' . $msg . "\n";
  132. }
  133. }
  134. $server = new QRServer();
  135. $server->run();

登录页面

  1. <!DOCTYPE html>
  2. <html >
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>扫码登录 - 测试页面</title>
  6. <meta name="viewport" content="width=device-width, initial-scale=1">
  7. <link rel="stylesheet" type="text/css" href="./public/css/main.css">
  8. </head>
  9. <body translate="no">
  10. <p class='box'>
  11. <p class='box-form'>
  12. <p class='box-login-tab'></p>
  13. <p class='box-login-title'>
  14. <p class='i i-login'></p><h2>登录</h2>
  15. </p>
  16. <p class='box-login'>
  17. <p class='fieldset-body' >
  18. <button onclick="openLoginInfo();" class='b b-form i i-more' title='Mais Informações'></button>
  19. <p class='field'>
  20. <label for='user'>用户账户</label>
  21. <input type='text' name='user' title='Username' placeholder="请输入用户账户/邮箱地址" />
  22. </p>
  23. <p class='field'>
  24. <label for='pass'>用户密码</label>
  25. <input type='password' name='pass' title='Password' placeholder="情输入账户密码" />
  26. </p>
  27. <label class='checkbox'>
  28. <input type='checkbox' value='TRUE' title='Keep me Signed in' /> 记住我 </label>
  29. <input type='submit' value='登录' title='登录' />
  30. </p>
  31. </p>
  32. </p>
  33. <p class='box-info'>
  34. <p><button onclick="closeLoginInfo();" class='b b-info i i-left' title='Back to Sign In'></button><h3>扫码登录</h3>
  35. </p>
  36. <p class='line-wh'></p>
  37. <p >
  38. <input type="hidden" value="">
  39. <p >二维码已失效<br>点击重新获取</p>
  40. <img src="" />
  41. </p>
  42. </p>
  43. </p>
  44. <script src='./public/js/jquery.min.js'></script>
  45. <script src='./public/js/modernizr.min.js'></script>
  46. <script >
  47. $(document).ready(function () {
  48. restQRCode();
  49. openLoginInfo();
  50. $('#qrcode-exp').click(function () {
  51. restQRCode();
  52. $(this).hide();
  53. });
  54. }); /**
  55. * 打开二维码 */
  56. function openLoginInfo() {
  57. $(document).ready(function () {
  58. $('.b-form').css("opacity", "0.01");
  59. $('.box-form').css("left", "-100px");
  60. $('.box-info').css("right", "-100px");
  61. });
  62. } /**
  63. * 关闭二维码 */
  64. function closeLoginInfo() {
  65. $(document).ready(function () {
  66. $('.b-form').css("opacity", "1");
  67. $('.box-form').css("left", "0px");
  68. $('.box-info').css("right", "-5px");
  69. });
  70. } /**
  71. * 刷新二维码 */
  72. var ws, wsTid = null;
  73. function restQRCode() {
  74. $.ajax({
  75. url: 'http://localhost/qrcode/code.php',
  76. type:'post',
  77. dataType: "json", async: false,
  78. success:function (result) {
  79. $('#qrcode').attr('src', result.img);
  80. $('#qid').val(result.qid);
  81. }
  82. }); if ("WebSocket" in window) { if (typeof ws != 'undefined'){
  83. ws.close(); null != wsTid && window.clearInterval(wsTid);
  84. }
  85. ws = new WebSocket("ws://loc.websocket.net?q#qid').val());
  86. ws.onopen = function() {
  87. console.log('websocket 已连接上!');
  88. };
  89. ws.onmessage = function(e) { // todo: 本函数做登录处理,登录判断,创建缓存信息! console.log(e.data); var result = JSON.parse(e.data);
  90. console.log(result);
  91. alert('登录成功:' + result.name);
  92. };
  93. ws.onclose = function() {
  94. console.log('websocket 连接已关闭!');
  95. $('#qrcode-exp').show(); null != wsTid && window.clearInterval(wsTid);
  96. }; // 发送心跳
  97. wsTid = window.setInterval( function () { if (typeof ws != 'undefined') ws.send('1');
  98. }, 50000 );
  99. } else { // todo: 不支持 WebSocket 的,可以使用 js 轮询处理,这里不作该功能实现!
  100. alert('您的浏览器不支持 WebSocket!');
  101. }
  102. }</script>
  103. </body>
  104. </html>

登录处理

测试使用,模拟登录处理,未做安全认证!!

  1. <?php
  2. require_once dirname(__FILE__) . '/lib/RedisUtile.php';
  3. require_once dirname(__FILE__) . '/lib/Common.php';/**
  4. * ------- 登录逻辑模拟 --------
  5. * 请根据实际编写登录逻辑并处理安全验证 */$qid = $_GET['qid'];
  6. $uid = $_GET['uid'];
  7. $data = array();switch ($uid)
  8. { case '1':
  9. $data['uid'] = 1;
  10. $data['name'] = '张三'; break; case '2':
  11. $data['uid'] = 2;
  12. $data['name'] = '李四'; break;
  13. }
  14. $data = json_encode($data);
  15. $redis = \lib\RedisUtile::getInstance();
  16. $redis->setex(\lib\Common::getQidLoginKey($qid), 1800, $data);