扫码登录原理和开发实践

导语:之前做过一个工具箱,其中登录模块中,有一个扫码登录的功能,模拟了客户端授权,web端自动登录的过程,今天就这个做一个总结归纳。

# 目录

  • 概念
  • 原理
  • 实现

# 概念

扫码登录,就是指当我们在PC端网站上面登录账号的时候,省去了输入用户名和密码的麻烦,只需要打开手机上的对应网站的APP,调用扫码功能,扫描网站上的二维码,确认登录,即可完成网页登录。

# 原理

我这里简单的画了一个示意图,是简化的扫码登录流程。

扫码登录原理

  • 游览器向服务端发起扫码请求;
  • 服务端返回唯一ID和二维码;
  • 游览器不断轮询查看扫码状态;
  • 服务端向APP端发起确认请求;
  • APP端弹出请求页面进行操作;
  • APP端确认/取消登录后发送服务端;
  • 服务端收到扫码结果后处理;
  • 确认登录返回用户token,否则返回提示;
  • 游览器收到状态后用户信息渲染页面;

# 实现

扫码登录涉及到游览器端,服务端和APP端,由于实际原因,APP端的确认/取消功能由游览器端代理模拟。

# 新建项目

新建一个名为scan的express项目。

express --view=ejs scan
cd scan
npm i
npm start
1
2
3
4

# 游览器端

这部分放在/public/web/文件夹下面。

  • 首页
<div class="scan">
    <div class="scan-inner">
        <div class="scan-qrcode">
            <div id="qrcode"></div>
            <span id="qrcode-text">请使用APP扫码登录</span>
        </div>
        <div id="scan-welcome">
            欢迎光临!
        </div>
    </div>
</div>
1
2
3
4
5
6
7
8
9
10
11
body {
    margin: 0;
    padding: 0;
    background-color: #f8f8f8;
}
.scan {
    display: flex;
    justify-content: center;
    align-items: center;
    height: 100vh;
}
.scan-inner {
    padding: 75px 0;
    max-width: 320px;
    width: 100%;
    text-align: center;
    box-shadow: 0 0 5px 3px #ddd;
    background-color: #fff;
}
.scan #qrcode {
    margin: 0 auto 20px auto;
    display: block;
    width: 150px;
    height: 150px;
    background-color: #f9f9f9;
    box-shadow: 0 0 5px #ccc;
}

.scan #qrcode.active {
    border: 3px solid #f00;
}

.scan span {
    font-size: 14px;
}

.scan span.active {
    color: #f00;
    font-weight: bold;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

看下页面效果。

web端

  • 变量元素
let timer = null,
qrcode = document.getElementById('qrcode'),
qrcodeText = document.getElementById('qrcode-text'),
scanQrcode = document.querySelector('.scan-qrcode'),
scanWelcome = document.getElementById('scan-welcome');


scanWelcome.style.display = 'none';
scanQrcode.style.display = 'block';

checkLogin();
1
2
3
4
5
6
7
8
9
10
11
  • 检测登录
// 检测登录
function checkLogin () { 
    let userInfo = localStorage.getItem('userInfo');
    if (!userInfo) {
        getQrcode();
    } else {
        scanWelcome.style.display = 'block';
        scanQrcode.style.display = 'none';
    }
}
1
2
3
4
5
6
7
8
9
10
  • 点击刷新二维码
// 点击获取二维码
qrcode.onclick = function () {  
    getQrcode();
}
1
2
3
4
  • 获取二维码
// 获取授权二维码地址
function getQrcode () {
    
    qrcode.className = '';
    qrcodeText.innerText = '请使用APP扫码登录';
    qrcodeText.className = '';
    const fpPromise = FingerprintJS.load();
    fpPromise
    .then((fp) => fp.get())
    .then(async (result) => {
        let bfp = result.visitorId;
        let data = await axios.post('/users/scan', {
            type: 'qrcode',
            uuid: bfp,
        });
        if (data.data.code === 200) {
            showQrcode(data.data.data.url);
            queryStatus(data.data.data.code);
        }
    });
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  • 显示二维码
// 显示二维码
async function showQrcode (url) {
    let data = await axios.get('/users/qrcode?url='+url);
    let qrcode = document.getElementById('qrcode');
    if (data.status === 200) {
        qrcode.innerHTML = data.data;
    }
}
1
2
3
4
5
6
7
8
  • 轮询查询
function queryStatus (code) {  
    timer = setInterval(() => {
        getStatus(code);
    }, 1000);
}
1
2
3
4
5
  • 获取状态
async function getStatus (code) {  
    let data = await axios.post('/users/scan', {
        type: 'status',
        code,
    })
    if (data.data.code === 200) {
        console.log(data.data.data);
        if (data.data.data.status === 'scaned') {
            qrcodeText.innerText = '用户已扫码!';
        }
        if (data.data.data.status === 'cancel') {
            qrcode.className = 'active';
            qrcodeText.innerText = '用户已取消!';
            qrcodeText.className = 'active';
        }
        if (data.data.data.status === 'confirm') {
            qrcode.className = '';
            qrcodeText.innerText = '用户扫码成功!';
            qrcodeText.className = '';
            localStorage.setItem('userInfo', JSON.stringify(data.data.data.user));
            clearInterval(timer);
            setTimeout(() => {
                scanWelcome.style.display = 'block';
                scanQrcode.style.display = 'none';
            }, 3000);
        }
    } else {
        if (data.data.data.status == 'invalid') {
            clearInterval(timer);
            qrcode.className = 'active';
            qrcodeText.innerText = data.data.data.info;
            qrcodeText.className = 'active';
        }
        
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

# APP端

这部分放在/public/app/文件夹下面。

  • 公用样式
body {
    margin: 0;
    padding: 0;
    background-color: #f8f8f8;
}
p {
    margin: 0;
}
.login {
    display: flex;
    justify-content: center;
    align-items: flex-start;
    height: 100vh;
}
.login-inner {
    max-width: 768px;
    width: 100%;
}
header {
    width: 100%;
    height: 55px;
    line-height: 55px;
    color: #fff;
    text-align: center;
    background-color: #333;
}
form,
.scan-box {
    box-sizing: border-box;
    margin: 20px auto;
    padding: 20px 15px;
    width: 90%;
    background-color: #fff;
    text-align: center;
}
p {
    height: 50px;
    line-height: 50px;
}

form input {
    width: 80%;
    height: 25px;
    line-height: 25px;
    outline: none;
    border: 1px solid #ccc;
    font-size: 12px;
}
form input::placeholder {
    font-size: 12px;
}
form label {
    width: 20%;
}

form input[type="submit"] {
    width: 100%;
    height: 30px;
    background-color: #f00;
    color: #fff;
    border: none;
}

.scan-box {
    display: flex;
    flex-direction: column;
    padding: 30px 15px;
}

.scan-box i {
    margin-bottom: 35px;
    font-size: 100px;
}

.scan-box button {
    margin: 0 auto;
    margin-bottom: 15px;
    width: 95%;
    height: 40px;
    outline: none;
    border: none;
    font-size: 14px;
    color: #fff;
    border-radius: 4px;
}

.confirm {
    background-color: #2d952d;
}

.confirm:hover {
    box-shadow: 0 0 3px #2d952d;
}

.cancel {
    background-color: #f11515;
}

.cancel:hover {
    box-shadow: 0 0 3px #f11515;
}

main {
    padding-left: 15px;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
  • 登录页面
<div class="login">
    <div class="login-inner">
        <header>APP登录</header>
        <form id="user">
            <p>
                <label for="username">用户:</label>
                <input type="text" placeholder="请输入用户名" name="username" id="username" required>
            </p>
            <p>
                <label for="password">密码:</label>
                <input type="password" placeholder="请输入密码" name="password" id="password" required>
            </p>
            <p>
                <input type="submit" value="登录">
            </p>
        </form>
    </div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  • 登录方法
let user = document.getElementById('user'),
username = document.getElementById('username'),
password = document.getElementById('password');
let code = getParams().code;
user.onsubmit = function () {
    userLogin();
    return false;
}

// 用户登录
async function userLogin () {  
    let data = await axios.post('/users/login', {
        username: username.value,
        password: password.value,
        code,
    });
    if (data.data.code === 200) {
        localStorage.setItem('userInfo', JSON.stringify(data.data.data.data));
        alert(data.data.data.info);
        setTimeout(() => {
            location.href = '/app/scan.html?code='+code;
        }, 1000);
    } else {
        alert(data.data.data.info);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

App端登录页

  • 扫码授权页面
<div class="login">
    <div class="login-inner">
        <header>扫码授权</header>
        <div class="scan-box">
            <i class="fa fa-solid fa-desktop"></i>
            <button class="confirm">确认登录</button>
            <button class="cancel">取消授权</button>
        </div>
    </div>
</div>
1
2
3
4
5
6
7
8
9
10
  • 检测登录
let params = getParams();

checkLogin();

// 检测登录
function checkLogin () { 
    let userInfo = localStorage.getItem('userInfo');
    if (userInfo) {
        userInfo = JSON.parse(userInfo);
        if (userInfo && userInfo.token && params.code) {
            sendScanStatus('scaned');
        }
    } else {
        location.href = './login.html?code='+params.code;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  • 发送状态
// 发送状态
async function sendScanStatus (status) {
    let userInfo = localStorage.getItem('userInfo');
    if (userInfo) {
        userInfo = JSON.parse(userInfo);
    }
    let data = await axios.post('/users/scan', {
        type: 'status',
        code: params.code,
        token: userInfo.token,
        data: {
            status,
        }
    });
    if (data.data.code === 101) {
        alert(data.data.data.info);
        setTimeout(() => {
            location.href = './';
        }, 1000);
    } else {
        let status = data.data.data.status;
        if (status === 'confirm' ||
        status === 'cancel') {
            setTimeout(() => {
                location.href = './';
            }, 1000);
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
  • 确认取消
// 确认取消
let confirmBtn = document.querySelector('.confirm'),
cancelBtn = document.querySelector('.cancel');
confirmBtn.onclick = function () {  
    sendScanStatus('confirm');
}
cancelBtn.onclick = function () {  
    sendScanStatus('cancel');
}
1
2
3
4
5
6
7
8
9

App端登录页

  • 首页
<div class="login">
    <div class="login-inner">
        <header>APP首页</header>
        <main>
            <h2>Hello,Friend!</h2>
            <p>Welcome to website.</p>
        </main>
    </div>
</div>
1
2
3
4
5
6
7
8
9

App端首页

# 服务端

这部分放在/routes/users.js文件中。

页面基本上写好了,接下来是服务端。

  • 获取二维码图片
npm i qr-image
1
// 引入二维码依赖包
var qrCode = require('qr-image');

// 生成二维码接口
router.get('/qrcode', function (req, res) {  
  let url = req.query.url;
  if (url) {
    let result = qrCode.imageSync(url, {
        type: 'svg'
    });
    res.send(result);
  } else {
    res.send('url is musted.');
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  • 令牌生成和验证
npm i jsonwebtoken
1
// config/api.js
var jwt = require('jsonwebtoken');
var secret = '123456';

function getToken (data, time = 60) {  
    let token = jwt.sign(
        {
            data,
        },
        secret,
        { 
            expiresIn: time,
        }
    );
    return token;
}

function verifyToken (token) {  
    return jwt.verify(token, secret, function (err, data) {
        if (err) {
            return {
                code: 101,
                data: null,
            }
        } else {
            return {
                code: 200,
                data,
            }
        }
    });
}

module.exports = {
    getToken,
    verifyToken,
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
  • 用户登录
// routes/users.js
var apis = require('../config/api');

router.post('/login', function (req, res) {  
  let params = req.body;
  if (params.username && params.password &&
    params.username === 'demo' &&
    params.password === '123456') {
      let userToken = apis.getToken({
        name: params.username,
        code: params.code,
      });
      return res.json({
        code: 200,
        msg: 'get_succ',
        data: {
          info: '用户登录成功!',
          data: {
            token: userToken,
          }
        }
      });
  } else {
    return res.json({
      code: 101,
      msg: 'get_fail',
      data: {
        info: '用户名或密码错误!',
      }
    });
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
  • 扫码接口
npm i xquuid
1
var xquuid = require('xquuid');
var scanStatus = ''; // 扫码状态
var userToken = ''; // 用户令牌

router.post('/scan', function (req, res) {  
  let params = req.body;

  // 获取二维码地址和ID
  if (params.type === 'qrcode') {
    scanStatus = 'scaning';
    userToken = '';
    let code = xquuid.Guid();
    let scanToken = apis.getToken({
      uuid: params.uuid,
      code,
    });
    let url = 'http://127.0.0.1:3000/app/scan.html?code='+scanToken;
    return res.json({
      code: 200,
      msg: 'get_succ',
      data: {
        code: scanToken,
        url,
      }
    })
  }

  // 响应轮询状态
  if (params.type === 'status') {
    let scanToken = apis.verifyToken(params.code);

    // 已过期
    if (scanToken.code == 101) {
      scanStatus = 'scaning';
      userToken = '';
      return res.json({
        code: 101,
        msg: 'get_fail',
        data: {
          info: '授权二维码已失效!',
          status: 'invalid',
        }
      })
    } else {

      // 已扫描
      if (params.data && params.data.status == 'scaned') {
        scanStatus = 'scaned';
      }

      // 已确认
      if (params.data && params.data.status == 'confirm') {
        scanStatus = 'confirm';
        userToken = params.token;
      }
 
      // 已取消
      if (params.data && params.data.status == 'cancel') {
        scanStatus = 'cancel';
      }

      if (scanStatus == 'confirm') {
        return res.json({
          code: 200,
          msg: 'get_succ',
          data: {
            status: scanStatus,
            user: userToken,
          }
        })
      } else {
        return res.json({
          code: 200,
          msg: 'get_succ',
          data: {
            status: scanStatus,
          }
        })
      }

    }
    
  }


})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86

# 写在最后

以上就是一个简单的扫码登录操作开发流程和步骤,有兴趣的可以自己根据文章实现体验一下。

当然可能会有一些疏漏,比如校验授权码等细节就没有顾得上仔细说,总体来说就是这样。

分享至:

  • qq
  • qq空间
  • 微博
  • 豆瓣
  • 贴吧