4. 增加邮箱验证功能
刚刚我们只是实现了登录的流程,不过里面用的验证码是假的,我们考虑下实现真正的邮箱验证码发送功能。
第三方包nodemailer为我们封装了邮件自动发送功能,它可以自己搭设一个邮箱服务器进行简单配置,即可自动发送邮件。官方英文文档参考这里。
安装nodemailer
$ npm install --save nodemailer
创建一个helpers目录下的mailer.js文件,这个文件封装发送邮件相关的逻辑,代码参考官方的Demo进行配置即可:
const nodemailer = require('nodemailer')
// node_modules/nodemailer/lib/well-known/services中提供了所有主流邮箱的配置列表
let transporter
const createMailServer = async function () {
// 创建一个可以测试用的邮件服务器,生产环境不要使用测试的邮件服务器,一定要部署公司的邮件服务器
let testAccount = await nodemailer.createTestAccount();
// 配置邮件服务器
// 这里是拿网易163账号做测试,你可以手动去网易注册一个163账号。
// 一定一定要注意,让你的邮箱开启SMTP服务才可以发送邮件。
return nodemailer.createTransport({
"host": "smtp.163.com",
"port": 465,
"secure": true,
auth: {
user: '你的用户名', // 你的注册邮箱账号,不需要加邮箱后缀@163.com
pass: 'R8qUs23VW4co' // 邮箱密码
}
});
}
// 基于随机值,生成动态的验证码
var generateVerifyCode = function (length) {
if (length < 4) {
throw new Error('验证码至少是4位')
}
var code = ''
for (var i = 0; i < length; i++) {
const r = Math.random() * 10 + ''
const values = r.split('.')
code = code.concat(values[0])
}
return code
}
// 发送邮件验证码,返回Promise的验证码结果
async function sendMail (to) {
if (!transporter) {
transporter = await createMailServer()
}
var verifyCode = generateVerifyCode(4)
// 发送邮件
var info = await transporter.sendMail({
from: '"扣扣音乐" <mingming_teacher@163.com>', // sender address
to: to, // list of receivers
subject: '验证码', // Subject line
text: '', // plain text body
html: '<p>您正在登录扣扣曲库管理,验证码是:<b>' + verifyCode + '</b></p>' // html body
});
if (info && info.accepted && info.accepted.includes(to)) {
// 说明发送成功了
return verifyCode
} else {
throw new Error('验证码发送失败')
}
}
module.exports = {
sendMail
}
可以先建一个test目录,其中编写测试代码测试一下接口好不好用,测试完成之后,修改routes/api.js的验证码验证逻辑。
var express = require('express');
var mailer = require('../helpers/mailer')
var router = express.Router();
var verifyCodeMap = new Map()
router.post('/email/verifyCode', function (req, res, next) {
const body = req.body
const email = body.email
if (email) {
mailer.sendMail(email).then((verifyCode) => {
verifyCodeMap.set(email, verifyCode)
console.log('邮件发送成功' + verifyCode)
res.send('验证码已经发送到您的邮箱,请注意查收')
}).catch((e) => {
console.log('邮件发送失败' + e.message)
res.status(500).send(e.message)
})
} else {
res.status(400).send('缺少email参数')
}
})
/* 登录页面 */
router.post('/login', function (req, res, next) {
const body = req.body
if (body.username === '小明' && body.password === '123456') {
const email = body.email && body.email.toLowerCase()
const verifyCode = body.verifyCode && body.verifyCode.toLowerCase()
if (verifyCodeMap.get(email) === verifyCode) {
req.session.isLogin = true
if (body.online === 'online') {
// 以后处理
}
res.send({
username: '小明',
age: 34,
school: '清华大学'
});
} else {
res.status(400).send('验证码验证错误')
}
} else {
res.status(401).send('登录失败')
}
});
修改前端页面login.ejs的获取验证码逻辑:
function postEmailVerifyCode () {
var value = document.getElementById('email').value
if (value) {
// 正则表达式记得去网站 https://regex101.com/ 验证一下再测试代码:
if (/^\S+@\S+\.\S+$/.test(value)) {
$.post('/api/v1/email/verifyCode', {email: value}, function (data) {
console.log('发送验证码成功')
alert(data)
})
} else {
alert('邮箱格式不正确')
}
} else {
alert('请填写邮箱')
}
}
之后测试结果,如果测试成功,恭喜你和老师一样成功的完成了邮箱验证码功能。
5. 使用jwt改进session
Session解决了HTTP无状态的问题,它可以通过每次请求的Session id来判断用户身份。但是,Session有一个比较明显的问题,就是如果服务器是分布式架构(集群服务器),会导致多个服务器之间的Session无法共享。因为Session是保存在服务器上的,因此解决这个问题非常困难。这时,JWT就应运而生。
什么JWT
JWT(全名JSON Web Token)摒弃了Session保存在服务器的而无法共享的问题,它把用户数据通过加密的方式直接存储在本地客户端,这样每次请求都把用户数据再回传回去,相当于客户端替代了服务器保存Session的工作。
JWT 的原理
服务器认证以后,生成一个用户信息的 JSON 对象,以后,客户端与服务端通信的时候,都要发回这个 JSON 对象。服务器完全只靠这个对象认定用户身份。为了防止用户篡改数据,服务器在生成这个对象的时候,会加上签名。
这样,服务器就不保存任何 Session 数据了,也就是说,服务器变成无状态了,从而比较容易实现扩展。
JWT 由三个部分组成:
Header(头部):一个 JSON 对象,描述 JWT 的元数据;
Payload(负载):一个 JSON 对象,用来存放实际需要传递的数据;
JWT 规定了7个官方字段,供选用:
iss (issuer):签发人
exp (expiration time):过期时间
sub (subject):主题
aud (audience):受众
nbf (Not Before):生效时间
iat (Issued At):签发时间
jti (JWT ID):编号
除了官方字段,你还可以在这个部分定义私有字段
Signature(签名):对前两部分的签名,防止数据篡改。
JWT保存的格式是:Header.Payload.Signature,例如:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
它实际的数据时:
Header:{
"alg": "HS256",
"typ": "JWT"
},
Payload:{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
},
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
your-256-bit-secret
)
JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。
JWT在加密使用时,需要使用非对称加密的方式,提供一个私钥和公钥。关于公钥私钥的概念,可以参考这里。
JWT的传输方式
客户端进行登录验证
服务器收到请求后验证用户身份,验证失败则返回失败结果,验证成功,把用户非敏感数据应用JWT的规则进行签名加密。
服务器把加密的JWT数据发送给客户端
客户端接收到之后存储在本地Cookie或者localstorage中。
客户端以后的每次请求都携带JWT数据,你可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP 请求的头信息Authorization字段里面。当然,也可以放到post请求的body里面。
服务器端验证Token是否合法,合法才会响应请求返回数据。
JWT有哪些缺点
JWT 的最大缺点是,由于服务器不保存 session 状态,一旦 JWT 签发了,在到期之前就会始终有效。
JWT 本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。因此,JWT 的有效期应该设置得比较短。
为了减少盗用,JWT 不应该使用 HTTP 协议明码传输,要使用 HTTPS 协议传输。
JWT的适用场景
保持用户登录状态
多个网站共享用户状态(第三方登录)
JWT和Session可以一起使用,Session用来保持用户状态,JWT用来验证用户身份。
接下来我们把Session机制改造成JWT的形式来处理用户登录。
安装
npm install --save jsonwebtoken
jsonwebtoken是Node.js上实现JWT的工具,中文参考这里。
使用
因为jwt是把token保存在客户端,因为不存在服务器端的清理机制,所以可以去掉logout接口。
改造登录接口,再添加一个验证接口用于验证token。
var express = require('express');
var mailer = require('../helpers/mailer')
var jwt = require('jsonwebtoken')
var router = express.Router();
var verifyCodeMap = new Map()
// 签名或私钥
var secret = 'fdsfdsfkljdskfjdsfkjdfkl'
/* 登录页面 */
router.post('/login', function (req, res, next) {
const body = req.body
if (body.username === '小明' && body.password === '123456') {
const email = body.email && body.email.toLowerCase()
const verifyCode = body.verifyCode && body.verifyCode.toLowerCase()
if (verifyCodeMap.get(email) === verifyCode) {
// req.session.isLogin = true
// if (body.online === 'online') {
// // 以后处理
// }
// res.send({
// username: '小明',
// age: 34,
// school: '清华大学'
// });
jwt.sign({isLogin: true, username: body.username, email: email}, secret, function (err, token) {
if (err) {
res.status(500).send('用户加密报错')
} else {
if (body.online === 'online') {
// 以后处理
}
res.send({
token,
username: '小明',
age: 34,
school: '清华大学'
});
}
});
} else {
res.status(400).send('验证码验证错误')
}
} else {
res.status(401).send('登录失败')
}
});
router.get('/users', function (req, res, next) {
const body = req.body
jwt.verify(body.token, secret, function(err, decoded) {
if(err){
res.status(401).send('用户身份验证失败');
}else{
console.log(decoded)
res.send(decoded)
}
});
})
module.exports = router;
6. 增删查改实现
a. 项目解耦和模块化
接下来我们要编写业务逻辑部分,这块是重头戏,在编写过程中,我们要更规范得去编写代码,因此需要对项目的目录结构进行一个合理划分。再来看下我们一开始创建项目时的README.md文件:
#### 软件架构
├── README.md - 项目文档<br/>
├── app.js - 初始化应用<br/>
├── bin - 命令脚本<br/>
├── database - 数据库配置<br/>
├── controllers - 定义路由处理的实现逻辑<br/>
├── helpers - 可以被项目各部分所调用的功能函数和代码<br/>
├── middlewares - Express 中间件,将要处理在进入路由之前的请求<br/>
├── models - 表示数据,实现业务逻辑和处理存储<br/>
├── package.json - 项目配置及其依赖的包<br/>
├── public - 包含所有的静态文件,像图片、样式和脚本<br/>
├── routes - 定义路由,这里的路由仅用于转发<br/>
├── tests - 测试在其他文件夹的的代码<br/>
└── views - 提供模板文件,模板文件将会在你路由中进行渲染和使用<br/>
我们需要按照这个目录结构去补充几个目录,包括database、controllers、models和对routes内部的代码进行解耦。
database处理和数据库操作有关的代码,但不包括增删改查的业务逻辑
routes只负责分发接口
controllers负责路由分发接口的数据预处理
models负责建立对象模型和增删改查
解耦是编程中的一个很常用但很关键的技巧,它是一种编程思想,核心思维是把一个模块分隔成几个独立的模块,这些模块之间通过接口来交互,以便让程序的代码更清晰,易于维护和单元测试。
b. 安装MongoDB和mongoose
MongoDB的安装方式上文已经学过,不再赘述,只需要记住需要通过mongod命令启动一下数据库。
安装mongoose
$ npm install --save mongoose
启动数据库,这里一定要注意,mongoose是会缓存查询操作的,如果查询老是报错,回头看一下数据库是否通过命令启动成功了
$ mongod
新建database/connect.js文件,用来编写数据库启动代码:
const mongoose = require('mongoose')
module.exports = function (callback) {
// 需要创建一个songs-manager用户,密码123456,设置可读写权限,来源test
mongoose.connect('mongodb://songs-manager:123456@127.0.0.1:27017/test', {
useNewUrlParser: true,
useUnifiedTopology: true,
useFindAndModify: false
});
var db = mongoose.connection;
db.on('error', function (e) {
callback(e)
});
db.once('open', function () {
console.log("数据库连接成功")
callback(null)
});
}
修改bin/www代码,让数据库启动成功之后再启动服务器:
#!/usr/bin/env node
// requires ....
var connectDatabase = require('../database/connect')
connectDatabase(function (e) {
if (e) {
return console.error(e)
}
// 把启动服务器的东西放到这里
})
c. 设计数据模型
服务器启动成功,我们就可以设计整个项目最核心的模块数据模型和增删查改了。
创建models/record.js文件,这里面的增删查改很容易出错,在编写完成代码后,一定要编写js代码测试下这个接口。测试之前记得保证已经通过mongod命令启动了数据库。
var mongoose = require('mongoose')
// 模式设计
var schema = new mongoose.Schema({
name: {type: String, required: true}, // 唱片名称
cover: {type: String, required: true}, //封面
singer: {type: String, required: true}, //歌手
publishTime: Date,// 发布时间
songs: [String]//歌曲名
})
var record = mongoose.model('records', schema);
module.exports = {
findRecords: function (filter, page, pageSize, callback) {
if (filter && page && pageSize && callback) {
record.find(filter, 'id name cover singer publishTime songs', {
skip: page * pageSize,
limit: parseInt(pageSize)
}, function (err, docs) {
callback(err, docs)
})
} else {
throw new Error('缺少参数或参数格式错误')
}
},
createRecord: function (data, callback) {
record.findOne(data, function (err, doc) {
if (err) {
return callback(err)
}
if (doc) {
return callback(new Error('同名专辑已存在'))
}
record.create(data, function (err, doc) {
callback(null, doc)
})
})
},
updateRecord: function (id, data, callback) {
record.findByIdAndUpdate(id, data, function (err, doc) {
callback(err, doc)
})
},
deleteRecord: function (id, callback) {
record.findByIdAndRemove(id, function (err, doc) {
callback(err, doc)
})
}
}
创建一个test/test.js文件,测试上面接口node ./test/test.js
var record = require('../models/record')
var connect = require('../database/connect')
// 必须先连接数据库才能测试,否则没有任何回调信息打印出来。
connect(function () {
// 新建,注意传入的数据格式值是JSON.stringify之后的数据
record.createRecord({
cover: "https://upload.wikimedia.org/wikipedia/zh/thumb/8/80/S.H.E_Super_Star.jpg/220px-S.H.E_Super_Star.jpg",
name: "Super Star",
publishTime: "2003-10-12T16:00:00.000Z",
singer: "S.H.E",
songs: "\"['半糖主义']\""
}, function (err, doc) {
if (err) {
throw err
}
if(!doc){
throw new Error('新建失败,数据库已存在同名数据')
}
console.log('新建成功', doc)
// 更新
record.updateRecord(doc._id, {...doc.toJSON(), singer: 'TFboys'}, function (err, doc) {
if (err) {
throw err
}
if(!doc){
throw new Error('更新失败,未找到数据项')
}
console.log('修改成功', doc)
// 查找
record.findRecords({id: doc._id}, "0", "10", function (err, docs){
if (err) {
throw err
}
console.log('查询成功', docs)
record.deleteRecord(doc._id, function (err, doc) {
if (err) {
throw err
}
console.log('删除成功', doc)
process.exit(0)
})
})
})
})
})
d. 编写路由规则
因为我们编写的是增删改查的接口,所有所有路由都放在api/v1下面。
修改routes/api.js,这里加入了jwt验证,请求的处理放到controllers/record.js中。
router.route('/records(/:id)?')
.all(function (req, res, next) {
next()
// 测试接口时,记得先删除用户校验
jwt.verify(body.token, secret, function (err, decoded) {
if (err) {
res.status(401).send('用户身份验证失败');
} else {
// 注意一定要next,否则进入不了后续的请求处理中
next()
}
});
})
.get(record.get)
.post(record.post)
.put(record.put)
.delete(record.del)
创建controllers/record.js文件,编写路由处理逻辑:
var {createRecord, updateRecord, deleteRecord, findRecords} = require('../models/record')
// 查询
const get = function (req, res) {
const {filter = {}, page, pageSize} = req.body
findRecords(filter, page, pageSize, function (err, docs) {
if (err) {
return res.status(400).send(err.message)
}
res.send(docs)
})
}
// 新建
const put = function (req, res) {
createRecord(req.body, function (err, doc) {
if (err) {
return res.status(400).send(err.message)
}
res.send(doc)
})
}
// 修改
const patch = function (req, res) {
// 这一这里使用params的id数据,body只用来存储传值数据
updateRecord(req.params.id, req.body, function (err, doc) {
if (err) {
return res.status(400).send(err.message)
}
if(doc){
// doc是被替换之前的文档,没什么用
res.send(doc)
}else{
res.status(400).send('未找到对应的数据')
}
})
}
// 删除
const del = function (req, res) {
// 这一这里使用params的id数据,body只用来存储传值数据
deleteRecord(req.params.id, function (err, doc) {
if (err) {
return res.status(400).send(err.message)
}
res.send(doc)
})
}
module.exports = {
get, put, patch, del
}
最后,通过PostMan测试下接口是否可行。