扫码登录实战系列 2: 后端接口实现

1,554 阅读5分钟

本文是扫码登录实战系列的第二篇,完整目录:

技术栈:Node.JS + express + MongoDB

构建工程

$ mkdir scan-qrcode-login && cd scan-qrcode-login
$ yarn init --yes
$ yarn add express mongoose moment
$ touch index.js

如果你还没有运行数据库,可以很快使用 docker 起一个:

docker run --name mongodb -p 27017:27017 -d mongodb

添加五个接口并开启数据库连接:

这里监听了 mongoose. connection.once('open') 事件,当数据库连接建立时调用 app.listen()

const express = require('express')
const mongoose = require('mongoose');
const bodyParser = require("body-parser")
const cors = require('cors')
const app = express()
const port = 8888

app.use(cors());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }))

// 二维码生成接口
app.get('/qrcode/gene', async (req, res) => {

})

// 二维码状态查询接口
app.get('/qrcode/check', async (req, res) => {

})

// 标记二维码已扫描接口
app.get('/qrcode/scanned', async (req, res) => {

})

// 同意授权接口
app.get('/qrcode/confirm', async (req, res) => {

})

// 取消授权接口
app.get('/qrcode/cancel', async (req, res) => {

})

connect();

function listen() {
  app.listen(port);
  console.log('Express app started on port ' + port);
}

function connect() {
  mongoose.connection
    .on('error', console.log)
    .on('disconnected', connect)
    .once('open', listen);
  return mongoose.connect('mongodb://localhost:27017/scan-qrcode', { keepAlive: 1, useNewUrlParser: true });
}

我们将在下面马上来实现这五个接口。

二维码生成接口

GET qrcode/gene

前面说过二维码信息本质上就是一段文本信息,所以我们需要将一段特定信息写入进二维码。常见的方式有下面几种:

  • 直接将二维码的标志 ID、创建时间、到期时间等相关信息写进去,这样客户端扫码解析的时候,就能够直接获取二维码 ID。(还记得前面讲的吗?扫码登录关键点是让客户端和 Web 端对二维码 ID 达成共识。)
  • 写入一个包含 ticket 的 url,可以通过此 ticket 获取二维码 ID。这有一个好处,比如说使用其他app扫描此二维码时,会访问该url地址,这个时候你就可以做一道重定向到其他你想让用户看到的地址。比如通过 UA 实现跳转 app store 引导用户下载。

创建 Model 先创建两个 Model:User 和 QRCode。

// models/user.js: 
const mongoose = require('mongoose');
const Schema = mongoose.Schema;

const UserSchema = new Schema({
  username: String,
  password: String,
  token: String
})

module.exports = mongoose.model('User', UserSchema);
// models/qrcode.js:

const mongoose = require('mongoose');
const Schema = mongoose.Schema;

const QRCodeSchema = new Schema({
  _allreadyUsed: {
    type: Boolean,
    default: false
  },
  userId: {
    type: Schema.Types.ObjectId,
    ref: "User"
  },
  url: String,
  // 是否已经被扫码
  scanned: {
    type: Boolean,
    default: false
  },
  status: {
    type: Number,
    default: 0 // 0 - 未确认;1 - 确认授权;-1 - 取消授权
  },
  // 用来换 userInfo
  ticket: String,
  userInfo: {
    type: Object,
    default: {}
  },

  createdAt: {
    type: Date,
    default: Date.now
  },
  expireAt: {
    type: Date
  }
});

module.exports = mongoose.model('QRCode', QRCodeSchema);
models/index.js:

const UserModel = require("./user")
const QRCodeModel = require("./qrcode")

module.exports = {
  UserModel,
  QRCodeModel
}

之后便可以在 index.js 中这样引入:

const { UserModel, QRCodeModel } = require("./models")

生成二维码用到 github.com/soldair/nod… 这个库,使用 yarn 安装:

yarn add qrcode

代码很简单,主要分成几步:

  • 将二维码存入数据库
  • 将 qrcodeData 转换成文本之后写入二维码
  • 返回结果
const moment = require("moment")
const QRCodeNode = require("qrcode");

app.get('/qrcode/gene', async (req, res) => {

  // 将二维码存入数据库
  const qrcode = new QRCodeModel({
    createdAt: Date.now(),
    expireAt: moment(Date.now()).add(120, 's').toDate(),
  })
  await qrcode.save()
    
  // 将 qrcodeData 转换成文本之后写入二维码
  let qrcodeData = {
    qrcodeId: qrcode._id,
    createdAt: qrcode.createdAt,
    expireAt: qrcode.expireAt,
  }
  const qrcodeUrl = await QRCodeNode.toDataURL(JSON.stringify(qrcodeData));

  // 返回结果
  res.send({
    code: 200,
    message: '生成二维码成功',
    data: {
      qrcodeId: qrcode._id,
      qrcodeUrl
    }
  })
})

之后访问 http://localhost:8888/qrcode/gene,就能够得到请求结果了:

二维码状态查询接口

GET /qrcode/check?qrcodeId=xxxxx

代码很简单:这里只有几点说明一下。而这也是我想告诉大家的接口设计最佳实践:

  • 这里将 scanned, expired, success, canceled 这些决定业务最终状态的结果计算出来,而不是只返回一个状态码,让前端自己去计算。
app.get('/qrcode/check', async (req, res) => {
  const { qrcodeId } = req.query;
  const qrcode = await QRCodeModel.findOne({ _id: qrcodeId })

  if (!qrcode) {
    res.send({
      code: 2241,
      message: '二维码不存在',
      data: null
    })
    return
  }

  res.send({
    code: 200,
    message: '查询二维码状态成功',
    data: {
      qrcodeId,
      scanned: qrcode.scanned,
      expired: moment() >= moment(qrcode.expireAt),
      success: qrcode.status === 1,
      canceled: qrcode.status === -1,
      status: qrcode.status,
      userInfo: qrcode.userInfo,
      ticket: qrcode.ticket,
    }
  })
})

标记已扫描接口

POST /qrcode/scanned

  • Body 参数:
    • qrcodeId: 二维码 ID

这个接口需要请求用户出于登录态,这一部分我们用 jwt 来实现。先实现两个 jwt 编码、解码的函数:

function generateToken(data, secret) {
  let iat = Math.floor(Date.now() / 1000);
  let exp = Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 15; // 有效期 15 天
  let token = jwt.sign(
    {
      data,
      iat,
      exp,
    },
    secret,
  );
  return token
}

function decryptToken(token, secret) {
  try {
    token = token.replace('Bearer ', '')
    let res = jwt.verify(token, secret);
    return res;
  } catch (err) {
    return false;
  }
}

再实现两个注册、登录函数:

app.post('/login', async (req, res) => {
  const { username, password } = req.body
  const user = await UserModel.findOne({
    username,
    password
  })
  if (!user) {
    res.send({
      code: 403,
      message: '用户名密码不正确'
    })
    return
  }

  const token = generateToken({ userId: user._id, username, avatar: user.avatar }, "s3cret")
  res.send({
    code: 200,
    message: '登录成功',
    data: {
      _id: user._id,
      username,
      token
    }
  })
})

app.post('/register', async (req, res) => {
  const { username, password } = req.body
  if ((await UserModel.findOne({ username, password }))) {
    res.send({
      code: 500,
      message: '用户名已被注册'
    })
    return
  }
  const user = new UserModel({
    username,
    password,
    avatar: "https://usercontents.authing.cn/authing-avatar.png"
  })
  await user.save()
  res.send({
    code: 200,
    message: '注册成功'
  })
})

打开任何 API 调试工具,模拟请求完成注册登录:

返回的 token 就是我们的登录凭证啦!

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7InVzZXJJZCI6IjVlMDc4YmY1ODZhZTVkZGZhNWE5NWM2NSJ9LCJpYXQiOjE1Nzc1NTMwOTEsImV4cCI6MTU3ODg0OTA5MX0.I9utsawxekpOzW-SplXCGEChGCa_3lb3FH5gOmVd0qM

接下来,完成一个认证中间件,用户判断当前请求的用户是否已登录:

  • 从 authorization 请求头中获取 token
  • 调用 decryptToken 尝试对此 token 进行解码
    • 如果成功:说明此 token 是系统签署的,标志此用于是处于登录态,可以继续执行下面的业务。
    • 如果失败,直接调用 res.send 结束请求。
const authenticated = async (req, res, next) => {
  const authorationToken = req.headers['authorization']
  const decoded = decryptToken(authorationToken, 's3cret')
  if (!decoded) {
    res.send({
      code: 403,
      message: '请先登录'
    })
    return
  }
  req.logged = true
  req.user = {
    userId: decoded.data.userId,
    username: decoded.data.username,
    avatar: decoded.data.avatar,
    token: authorationToken
  }
  await next()
}

给 /qrcode/scanned 加上此中间件并完成两个操作:

  • 标记二维码 scanned 字段为 true
  • 将用户名和头像保存至二维码的 userInfo 字段。这里只保存这两项,用于前端监控到用于已扫码时展示头像。参考微信 web 端扫码登录:
  const { qrcodeId } = req.body
  const qrcode = await QRCodeModel.findOne({ _id: qrcodeId })

  if (!qrcode) {
    res.send({
      code: 2241,
      message: '二维码不存在',
      data: null
    })
    return
  }
  await QRCodeModel.findOneAndUpdate({ _id: qrcodeId }, {
    scanned: true, userInfo: {
      username: req.user.username,
      avatar: req.user.avatar
    }
  })
  res.send({
    code: 200,
    message: '扫码成功'
  })
})

同意授权接口

  • 将二维码 status 改为 1
  • 二维码 userInfo 写入用户的完整信息
    • 包含 token 和用户 id
      • 前端得到 token 后就可以完成跳转了
app.post('/qrcode/confirm', authenticated, async (req, res) => {
  const { qrcodeId } = req.body
  const qrcode = await QRCodeModel.findOne({ _id: qrcodeId })

  if (!qrcode) {
    res.send({
      code: 2241,
      message: '二维码不存在',
      data: null
    })
    return
  }
  await QRCodeModel.findOneAndUpdate({ _id: qrcodeId }, {
    status: 1, userInfo: req.user
  })
  res.send({
    code: 200,
    message: '扫码成功'
  })
})

取消授权接口

app.post('/qrcode/cancel', async (req, res) => {
  const { qrcodeId } = req.body
  const qrcode = await QRCodeModel.findOne({ _id: qrcodeId })

  if (!qrcode) {
    res.send({
      code: 2241,
      message: '二维码不存在',
      data: null
    })
    return
  }
  await QRCodeModel.findOneAndUpdate({ _id: qrcodeId }, {
    status: -1,
  })
  res.send({
    code: 200,
    message: '扫码成功'
  })
})