Node.js课程知识讲解大全(六)

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测试下接口是否可行。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,457评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,837评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,696评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,183评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,057评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,105评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,520评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,211评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,482评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,574评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,353评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,897评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,489评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,683评论 2 335

推荐阅读更多精彩内容