本文是扫码登录实战系列的第二篇,完整目录:
技术栈: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 后就可以完成跳转了
- 包含 token 和用户 id
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: '扫码成功'
})
})