4. 增加郵箱驗證功能
剛剛我們只是實現(xiàn)了登錄的流程观游,不過里面用的驗證碼是假的旱爆,我們考慮下實現(xiàn)真正的郵箱驗證碼發(fā)送功能缰犁。
第三方包nodemailer為我們封裝了郵件自動發(fā)送功能刑枝,它可以自己搭設一個郵箱服務器進行簡單配置鬼佣,即可自動發(fā)送郵件。官方英文文檔參考這里额获。
安裝nodemailer
$ npm install --save nodemailer
創(chuàng)建一個helpers目錄下的mailer.js文件够庙,這個文件封裝發(fā)送郵件相關(guān)的邏輯,代碼參考官方的Demo進行配置即可:
const nodemailer = require('nodemailer')
// node_modules/nodemailer/lib/well-known/services中提供了所有主流郵箱的配置列表
let transporter
const createMailServer = async function () {
? // 創(chuàng)建一個可以測試用的郵件服務器抄邀,生產(chǎn)環(huán)境不要使用測試的郵件服務器首启,一定要部署公司的郵件服務器
? let testAccount = await nodemailer.createTestAccount();
? // 配置郵件服務器
? // 這里是拿網(wǎng)易163賬號做測試,你可以手動去網(wǎng)易注冊一個163賬號撤摸。
? // 一定一定要注意,讓你的郵箱開啟SMTP服務才可以發(fā)送郵件。
? return nodemailer.createTransport({
? ? "host": "smtp.163.com",
? ? "port": 465,
? ? "secure": true,
? ? auth: {
? ? ? user: '你的用戶名', // 你的注冊郵箱賬號准夷,不需要加郵箱后綴@163.com
? ? ? pass: 'R8qUs23VW4co' // 郵箱密碼
? ? }
? });
}
// 基于隨機值钥飞,生成動態(tài)的驗證碼
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
}
// 發(fā)送郵件驗證碼,返回Promise的驗證碼結(jié)果
async function sendMail (to) {
? if (!transporter) {
? ? transporter = await createMailServer()
? }
? var verifyCode = generateVerifyCode(4)
? // 發(fā)送郵件
? 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)) {
? ? // 說明發(fā)送成功了
? ? return verifyCode
? } else {
? ? throw new Error('驗證碼發(fā)送失敗')
? }
}
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('郵件發(fā)送成功' + verifyCode)
? ? ? res.send('驗證碼已經(jīng)發(fā)送到您的郵箱,請注意查收')
? ? }).catch((e) => {
? ? ? console.log('郵件發(fā)送失敗' + e.message)
? ? ? res.status(500).send(e.message)
? ? })
? } else {
? ? res.status(400).send('缺少email參數(shù)')
? }
})
/* 登錄頁面 */
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) {
? ? // 正則表達式記得去網(wǎng)站 https://regex101.com/ 驗證一下再測試代碼:
? ? if (/^\S+@\S+\.\S+$/.test(value)) {
? ? ? $.post('/api/v1/email/verifyCode', {email: value}, function (data) {
? ? ? ? console.log('發(fā)送驗證碼成功')
? ? ? ? alert(data)
? ? ? })
? ? } else {
? ? ? alert('郵箱格式不正確')
? ? }
? } else {
? ? alert('請?zhí)顚戉]箱')
? }
}
之后測試結(jié)果酒朵,如果測試成功桦锄,恭喜你和老師一樣成功的完成了郵箱驗證碼功能。
5. 使用jwt改進session
Session解決了HTTP無狀態(tài)的問題蔫耽,它可以通過每次請求的Session id來判斷用戶身份结耀。但是,Session有一個比較明顯的問題匙铡,就是如果服務器是分布式架構(gòu)(集群服務器)图甜,會導致多個服務器之間的Session無法共享。因為Session是保存在服務器上的鳖眼,因此解決這個問題非常困難黑毅。這時,JWT就應運而生钦讳。
什么JWT
JWT(全名JSON Web Token)摒棄了Session保存在服務器的而無法共享的問題矿瘦,它把用戶數(shù)據(jù)通過加密的方式直接存儲在本地客戶端,這樣每次請求都把用戶數(shù)據(jù)再回傳回去蜂厅,相當于客戶端替代了服務器保存Session的工作匪凡。
JWT 的原理
服務器認證以后,生成一個用戶信息的 JSON 對象掘猿,以后病游,客戶端與服務端通信的時候,都要發(fā)回這個 JSON 對象稠通。服務器完全只靠這個對象認定用戶身份衬衬。為了防止用戶篡改數(shù)據(jù),服務器在生成這個對象的時候改橘,會加上簽名滋尉。
這樣,服務器就不保存任何 Session 數(shù)據(jù)了飞主,也就是說狮惜,服務器變成無狀態(tài)了高诺,從而比較容易實現(xiàn)擴展。
JWT 由三個部分組成:
Header(頭部):一個 JSON 對象碾篡,描述 JWT 的元數(shù)據(jù)虱而;
Payload(負載):一個 JSON 對象,用來存放實際需要傳遞的數(shù)據(jù)开泽;
JWT 規(guī)定了7個官方字段牡拇,供選用:
iss (issuer):簽發(fā)人
exp (expiration time):過期時間
sub (subject):主題
aud (audience):受眾
nbf (Not Before):生效時間
iat (Issued At):簽發(fā)時間
jti (JWT ID):編號
除了官方字段,你還可以在這個部分定義私有字段
Signature(簽名):對前兩部分的簽名穆律,防止數(shù)據(jù)篡改惠呼。
JWT保存的格式是:Header.Payload.Signature固以,例如:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
它實際的數(shù)據(jù)時:
Header:{
? "alg": "HS256",
? "typ": "JWT"
}易迹,
Payload:{
? "sub": "1234567890",
? "name": "John Doe",
? "iat": 1516239022
},
HMACSHA256(
? base64UrlEncode(header) + "." +
? base64UrlEncode(payload),
your-256-bit-secret
)
JWT 默認是不加密的大磺,任何人都可以讀到贡歧,所以不要把秘密信息放在這個部分滩租。
JWT在加密使用時,需要使用非對稱加密的方式利朵,提供一個私鑰和公鑰律想。關(guān)于公鑰私鑰的概念,可以參考這里绍弟。
JWT的傳輸方式
客戶端進行登錄驗證
服務器收到請求后驗證用戶身份技即,驗證失敗則返回失敗結(jié)果,驗證成功樟遣,把用戶非敏感數(shù)據(jù)應用JWT的規(guī)則進行簽名加密而叼。
服務器把加密的JWT數(shù)據(jù)發(fā)送給客戶端
客戶端接收到之后存儲在本地Cookie或者localstorage中。
客戶端以后的每次請求都攜帶JWT數(shù)據(jù)豹悬,你可以把它放在 Cookie 里面自動發(fā)送葵陵,但是這樣不能跨域,所以更好的做法是放在 HTTP 請求的頭信息Authorization字段里面瞻佛。當然脱篙,也可以放到post請求的body里面。
服務器端驗證Token是否合法伤柄,合法才會響應請求返回數(shù)據(jù)绊困。
JWT有哪些缺點
JWT 的最大缺點是,由于服務器不保存 session 狀態(tài)适刀,一旦 JWT 簽發(fā)了秤朗,在到期之前就會始終有效。
JWT 本身包含了認證信息笔喉,一旦泄露取视,任何人都可以獲得該令牌的所有權(quán)限硝皂。因此,JWT 的有效期應該設置得比較短贫途。
為了減少盜用吧彪,JWT 不應該使用 HTTP 協(xié)議明碼傳輸,要使用 HTTPS 協(xié)議傳輸丢早。
JWT的適用場景
保持用戶登錄狀態(tài)
多個網(wǎng)站共享用戶狀態(tài)(第三方登錄)
JWT和Session可以一起使用,Session用來保持用戶狀態(tài)秧倾,JWT用來驗證用戶身份怨酝。
接下來我們把Session機制改造成JWT的形式來處理用戶登錄。
安裝
npm install --save jsonwebtoken
jsonwebtoken是Node.js上實現(xiàn)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. 增刪查改實現(xiàn)
a. 項目解耦和模塊化
接下來我們要編寫業(yè)務邏輯部分揍堕,這塊是重頭戲,在編寫過程中汤纸,我們要更規(guī)范得去編寫代碼衩茸,因此需要對項目的目錄結(jié)構(gòu)進行一個合理劃分。再來看下我們一開始創(chuàng)建項目時的README.md文件:
#### 軟件架構(gòu)
├── README.md - 項目文檔<br/>
├── app.js - 初始化應用<br/>
├── bin - 命令腳本<br/>
├── database - 數(shù)據(jù)庫配置<br/>
├── controllers - 定義路由處理的實現(xiàn)邏輯<br/>
├── helpers - 可以被項目各部分所調(diào)用的功能函數(shù)和代碼<br/>
├── middlewares - Express 中間件贮泞,將要處理在進入路由之前的請求<br/>
├── models - 表示數(shù)據(jù)楞慈,實現(xiàn)業(yè)務邏輯和處理存儲<br/>
├── package.json - 項目配置及其依賴的包<br/>
├── public - 包含所有的靜態(tài)文件,像圖片啃擦、樣式和腳本<br/>
├── routes - 定義路由,這里的路由僅用于轉(zhuǎn)發(fā)<br/>
├── tests - 測試在其他文件夾的的代碼<br/>
└── views - 提供模板文件囊蓝,模板文件將會在你路由中進行渲染和使用<br/>
我們需要按照這個目錄結(jié)構(gòu)去補充幾個目錄,包括database令蛉、controllers聚霜、models和對routes內(nèi)部的代碼進行解耦。
database處理和數(shù)據(jù)庫操作有關(guān)的代碼言询,但不包括增刪改查的業(yè)務邏輯
routes只負責分發(fā)接口
controllers負責路由分發(fā)接口的數(shù)據(jù)預處理
models負責建立對象模型和增刪改查
解耦是編程中的一個很常用但很關(guān)鍵的技巧俯萎,它是一種編程思想,核心思維是把一個模塊分隔成幾個獨立的模塊运杭,這些模塊之間通過接口來交互夫啊,以便讓程序的代碼更清晰,易于維護和單元測試辆憔。
b. 安裝MongoDB和mongoose
MongoDB的安裝方式上文已經(jīng)學過撇眯,不再贅述报嵌,只需要記住需要通過mongod命令啟動一下數(shù)據(jù)庫。
安裝mongoose
$ npm install --save mongoose
啟動數(shù)據(jù)庫熊榛,這里一定要注意锚国,mongoose是會緩存查詢操作的,如果查詢老是報錯玄坦,回頭看一下數(shù)據(jù)庫是否通過命令啟動成功了
$ mongod
新建database/connect.js文件血筑,用來編寫數(shù)據(jù)庫啟動代碼:
const mongoose = require('mongoose')
module.exports = function (callback) {
? // 需要創(chuàng)建一個songs-manager用戶,密碼123456煎楣,設置可讀寫權(quán)限豺总,來源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("數(shù)據(jù)庫連接成功")
? ? callback(null)
? });
}
修改bin/www代碼,讓數(shù)據(jù)庫啟動成功之后再啟動服務器:
#!/usr/bin/env node
// requires ....
var connectDatabase = require('../database/connect')
connectDatabase(function (e) {
? if (e) {
? ? return console.error(e)
? }
// 把啟動服務器的東西放到這里
})
c. 設計數(shù)據(jù)模型
服務器啟動成功择懂,我們就可以設計整個項目最核心的模塊數(shù)據(jù)模型和增刪查改了喻喳。
創(chuàng)建models/record.js文件,這里面的增刪查改很容易出錯困曙,在編寫完成代碼后表伦,一定要編寫js代碼測試下這個接口。測試之前記得保證已經(jīng)通過mongod命令啟動了數(shù)據(jù)庫慷丽。
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,// 發(fā)布時間
? 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('缺少參數(shù)或參數(shù)格式錯誤')
? ? }
? },
? 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)
? ? })
? }
}
創(chuàng)建一個test/test.js文件蹦哼,測試上面接口node ./test/test.js
var record = require('../models/record')
var connect = require('../database/connect')
// 必須先連接數(shù)據(jù)庫才能測試,否則沒有任何回調(diào)信息打印出來盈魁。
connect(function () {
? // 新建翔怎,注意傳入的數(shù)據(jù)格式值是JSON.stringify之后的數(shù)據(jù)
? 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('新建失敗,數(shù)據(jù)庫已存在同名數(shù)據(jù)')
? ? }
? ? console.log('新建成功', doc)
? ? // 更新
? ? record.updateRecord(doc._id, {...doc.toJSON(), singer: 'TFboys'}, function (err, doc) {
? ? ? if (err) {
? ? ? ? throw err
? ? ? }
? ? ? if(!doc){
? ? ? ? throw new Error('更新失敗杨耙,未找到數(shù)據(jù)項')
? ? ? }
? ? ? 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. 編寫路由規(guī)則
因為我們編寫的是增刪改查的接口赤套,所有所有路由都放在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剔氏,否則進入不了后續(xù)的請求處理中
? ? ? ? next()
? ? ? }
? ? });
? })
? .get(record.get)
? .post(record.post)
? .put(record.put)
? .delete(record.del)
創(chuàng)建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數(shù)據(jù)竹祷,body只用來存儲傳值數(shù)據(jù)
? 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('未找到對應的數(shù)據(jù)')
? ? }
? })
}
// 刪除
const del = function (req, res) {
? // 這一這里使用params的id數(shù)據(jù),body只用來存儲傳值數(shù)據(jù)
? 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測試下接口是否可行感憾。