Blog Logo

教你用Koa发送手机验证码实现注册登录

写于2017-07-02 13:18 阅读耗时16分钟 阅读量


1.Koa是什么

Koa

Koa是下一代的Node.js的Web框架。既然说是下一代,那么肯定有上一代咯。 没错,上一代Node.js的Web框架叫Express。

Express

要问它俩有什么共同点,它俩的共同点是两个框架来自同一个团队。 要问它俩最大的不同点,它俩最大的不同点就是轻与重

讲人话,Koa到底如何理解呢?简单理解,Koa就是一个轻量的服务器,与Java的Tomact类似。


2.Koa的特点

1.轻量

Koa的核心文件总共不超过40KB,Koa只提供封装好的http上下文、请求、响应,以及基于async/await的中间件容器。 KoaFile


2.灵活

轻的好处当然就是灵活,可以直接使用Koa的各类包,来满足你对业务不同的需求。NPM官网到目前为止,已经收录2584个关于Koa的npm包。 KoaPackage


3.先进

这个怎么解释呢?koa2和koa1有很大的区别,koa2利用ES7的async/await的来处理传统回调嵌套问题和代替koa1的generator。 可能听不懂什么意思,没关系,简单讲就是为解决异步。 以前的ES5,接着ES6,最后ES7,举个简单例子。

1.使用ES5传统回调嵌套

function ajaxs(callback){
    $.get('a.html',function(dataa) {
        console.log(dataa);
        $.get('b.html',function(datab) {
            console.log(datab);
            $.get('c.html',function(datac) {
                console.log(datac);
            	callback();
            });
        });
    });
}

ajaxs(function(){
    console.log('OK');
});

2.使用ES6的generator

function request(url) {
  $.get(url, function(response){
    console.log(response);
    it.next(response);
  });
}
function* ajaxs() {
    yield request('a.html');
    yield request('b.html');
    yield request('c.html');
    return console.log('OK');
}
let it = ajaxs();
it.next();

3.使用ES7的async/await

async function request(url) {
  await $.get(url, function(response){
    console.log(response);
  });
}
async function ajaxs() {
    await request('a.html');
    await request('b.html');
    await request('c.html');
    console.log('OK');
}
ajaxs();

koa2可以让异步逻辑用同步写法实现,因为其支持asyncawait。该特性可以通过多层 async function 的同步写法代替传统的callback嵌套。 这样写的后端代码逻辑既清晰,又美观,是不是很先进。以后如果还有能解决异步的最佳方法,相信koa也会与时俱进,及时跟进,就目前而言,使用async/await是解决异步的最佳方法


3.Koa的中间件

中间件是什么?中间件就是指上面的2584个关于Koa的npm包,有点类似Java的jar包。 使用任意一个npm包可没有像Java那么简单,Java只需要导入jar包后build path即可,而JS需要引入且配置。

下面主要讲解常用的koa中间件:koa-router、koa-logger、koa-session、koa-bodyparser。 当然koa和mongoose整合也是必不可少的。 直接上完整的项目代码,首先看下整体的目录结构: XJS

目录讲解:

app/models 模型层,定义各种表结构
app/controllers 业务层,实现各种功能
app/service 服务层,调用第三方的服务,如:发短信验证码

config 配置请求路径

app.js 服务器主入口

1.主入口app.js功能讲解

'use strict'

/**
 *  mongoose Node连接MongoDB数据库
 *  bluebird 功能全面的Promise库
 *  koa web框架核心库
 *  koa-router 路由中间件
 *  koa-logger 打印日志中间件
 *  koa-session session中间件
 *  koa-bodyparser body解析器(如:POST请求传参)
 *  
 *  speakeasy 验证码生成器
 *  xss  防止xss攻击
 *  uuid 唯一识别码token
 *  sms  发送螺丝帽短信工具
 *
 **/

//引入node读取文件和路径库
const fs = require('fs')
const path = require('path')

//引入mongoose,配置MongoDB数据库
const mongoose = require('mongoose')
const url = 'mongodb://localhost/xjs'

//mongoose默认的promise库已过时
mongoose.Promise = require('bluebird')

//创建数据库连接
//方法1
//const db = mongoose.createConnection('localhost', 'xjs');
//方法2
mongoose.connect(url);
const db = mongoose.connection;


//监听数据库连接状态
db.on('error', ctx => console.log('连接异常:' + ctx))
db.on('connected', ctx => console.log('连接成功'))
db.on('disconnected', ctx => console.log('连接断开'))

//当前models路径
const models_path = path.join(__dirname, '/app/models')

//循环读取models
const walk = function(modelPath) {
  fs
    .readdirSync(modelPath)
    .forEach(function(file) {
      let filePath = path.join(modelPath, '/' + file)
      let stat = fs.statSync(filePath)
      console.log(filePath);
      if (stat.isFile()) {
        if (/(.*)\.(js|coffee)/.test(file)) {
          require(filePath)
        }
      } else if (stat.isDirectory()) {
        walk(filePath)
      }
    })
}

//执行
walk(models_path)

//引入Koa框架
const Koa = require('koa')
const logger = require('koa-logger')
const session = require('koa-session')
const bodyParser = require('koa-bodyparser')

//实例化koa
const app = new Koa()

//使用中间件
//引入koa-logger,支持日志打印
app.use(logger())

//引入koa-bodyparser,支持body为json、form、text格式
app.use(bodyParser())

//引入session,设置自定义的cookie名,默认是koa.sid
app.keys = ['xjs']
app.use(session(app))

//引入koa-router配置路由
const router = require('./config/routes')()
app.use(router.routes()).use(router.allowedMethods())


//监听端口3001
app.listen(3001)
console.log('Listening:3001')

1.引入nodejs核心功能模块fs、path

path:处理文件的路径 fs:提供本地文件的读写能力

2.配置MongoDB数据库

a.使用fs、path循环读取models文件夹下面的文件,导入多个表结构model b.修改mongoose已过时的promise库,将其修改成bluebird c.配置本地具体的数据库,开启连接 d.监听数据库连接状态

3.引入Koa中间件

a.koa-logger,实现控制台日志的打印 b.koa-bodyparser,实现Post请求传参 c.koa-session,需要用到session修改用户信息 d.koa-router,配置请求路由

4.启动Koa服务器,监听3001端口


2.路由routes.js功能讲解

'use strict'
//引入koa-router中间件
const Router = require('koa-router')

//引入业务层
const User = require('../app/controllers/user')
const App = require('../app/controllers/app')

//导出一个匿名方法在配置koa时调用(app.js)
module.exports = function() {
	//实例化一个router,并声明前缀
	let router = new Router({
		prefix: '/api'
	});
	//用户请求 中间件依次执行
	router.post('/user/signup', App.hasBody, User.signup);
	router.post('/user/verify', App.hasBody, User.verify);
	router.post('/user/update', App.hasBody, App.hasToken, User.update);

	//公用请求或工具请求,如:加密,校验accessToken
	router.get('/app/jm', App.jm);

	//TODO
	return router;
}

1.引入koa-router,引入业务层,如用户、公用的方法。

2.配置各类请求及业务层对应的方法,如用户的获取验证码、验证登录、修改用户昵称等请求。

3.配置时可以使用中间件,举例说明:

router.post('/user/update', App.hasBody, App.hasToken, User.update);

该方法的意思是配置一个地址为:api/user/update的方法,该方法能够更新用户的昵称。 当用户确认修改昵称后,会依次执行公共业务层的hasBody、hasToken及用户业务层的update方法。 hasBody方法判断参数是否缺少、hasToken方法判断是否有accessToken用户的唯一标识,update方法用来更新用户昵称。 这是个递进的方法,一层一层拦截,如果没参数不会执行下面的方法,依次类推。


3.模型层user.js讲解

'use strict'
//引入mongoose
const mongoose = require('mongoose')

//定义表结构
const UserSchema = new mongoose.Schema({
	number: { //手机号
		unique: true,
		type: String
	},
	code: String, //验证码
	accessToken: String, //用户唯一标识
	nickname: String, //昵称
	avatar: String, //头像
	verified: { //是否验证过
		type: Boolean,
		default: false
	},
	meta: {
		createAt: { //创建时间
			type: Date,
			default: Date.now()
		},
		updateAt: { //更新时间
			type: Date,
			default: Date.now()
		}
	}
})

//定义添加数据的拦截器
UserSchema.pre('save', function(next) {
	if (!this.isNew) { //老数据
		this.meta.updateAt = Date.now()
	}
	next()
})

//导出用户Model
module.exports = mongoose.model('User', UserSchema)

1.定义用户表结构,如手机号String类型且唯一,验证码String,是否验证过Boolean类型且默认是没验证过,创建和更新时间Date类似且默认是当前服务器时间。

2.定义拦截器,功能是如果是老数据,记录下更新时间。

3.导出用户Model,记录用户Model在mongoose里面,方便在业务层里面调用,调用如下:

const mongoose = require('mongoose')
const User = mongoose.model('User')

4.业务层user.js讲解

/**
 *	xss  防止xss攻击
 *	uuid 唯一识别码token
 *	sms  发送螺丝帽短信工具
 **/

const xss = require('xss')
const mongoose = require('mongoose')
const User = mongoose.model('User')
const uuid = require('uuid')
const sms = require('../service/sms')

//获取验证码
exports.signup = async ctx => {
	let number = ctx.request.body.number; //post
	//let number = ctx.query.number;//get
	let user = await User.findOne({
		number: number
	}).exec();

	let code = sms.getCode();
	console.log(code);

	if (!user) { //新用户
		let accessToken = uuid.v4();
		user = new User({
			number: xss(number),
			code: code,
			accessToken: accessToken
		})
		console.log('新用户');
	} else {
		user.code = code;
		console.log('老用户');
	}
	try {
		await user.save();
		//await sms.send(number, code);
		ctx.body = {
			success: true,
			msg: '验证码已发送'
		};
	} catch (e) {
		ctx.body = {
			success: false,
			msg: '验证码发送失败'
		};
	}
}

//验证登录
exports.verify = async ctx => {
	let code = ctx.request.body.code;
	let number = ctx.request.body.number;

	if (!code || !number) {
		ctx.body = {
			success: false,
			msg: '验证没通过'
		}
		return ctx;
	}

	let user = await User.findOne({
		number: number,
		code: code
	}).exec();
	if (user) {
		user.verified = true;
		user = await user.save();
		ctx.body = {
			success: true,
			msg: '验证通过',
			data: user
		}
	} else {
		ctx.body = {
			success: false,
			msg: '验证没通过'
		}
	}
}

//修改用户昵称
exports.update = async ctx => {
	let body = ctx.request.body;
	let user = ctx.session.user;
	user.nickname = xss(body['nickname'].trim());
	user = await user.save();
	ctx.body = {
		success: true,
		data: {
			nickname: user.nickname,
			_id: user._id
		}
	}
}

1.引入其他第三方工具库,xss防止xss攻击的,uuid获取唯一识别码保存accessToken的,sms自定义发送短信的。

2.使用最新的ES7asyncawait,再加上ES6的语法,代码简单清爽,一目了然。

3.GET请求和POST请求在获取前端的参数上有所不同:

GET请求:

let number = ctx.query.number;//从query里面获取

POST请求:

let number = ctx.request.body.number;//从request的body里面获取

4.可以通过上下文的session直接获取到当前用户的信息

let user = ctx.session.user;

5.可直接使用模型层User进行CRUD操作:

let user = User.findOne(...);//查询一条用户数据
user.save();//添加或更新用户数据

5.业务层app.js讲解

//引入mongoose
const mongoose = require('mongoose')

//引入用户Model
const User = mongoose.model('User')

//导出加密方法 暂未写
exports.jm = ctx => {
	console.log(ctx);
	console.log(ctx.method);
	ctx.body = '加密';
}

//导出是否缺少参数方法
exports.hasBody = async(ctx, next) => {
	let body = ctx.request.body || {}
	if (Object.keys(body).length === 0) {
		ctx.body = {
			success: false,
			msg: '参数缺失'
		}
		return ctx
	}
	await next();
}

//导出是否有accessToken方法
exports.hasToken = async(ctx, next) => {
	var accessToken = ctx.query.accessToken
	if (!accessToken) {
		accessToken = ctx.request.body.accessToken
	}
	if (!accessToken) {
		ctx.body = {
			success: false,
			msg: '没accessToken'
		}
		return ctx
	}
	var user = await User.findOne({
		accessToken: accessToken
	}).exec();

	if (!user) {
		ctx.body = {
			success: false,
			msg: '用户没登录'
		}
		return ctx
	}
	ctx.session = ctx.session || {}
	ctx.session.user = user;
	await next();
}

1.注意在hasToken方法里,如果该用户的accessToken存在,则获取到该用户,且放进session里面供业务层user.js使用。

ctx.session.user = user;//设置该用户信息进入session

2.hasToken方法可以在需要校验用户登录后才能正常调用的接口,在配置路径的时候很方便。

如:更新用户昵称、上传用户头像、支付等。

router.post('/user/update', App.hasBody, App.hasToken, User.update);//更新用户昵称
router.post('/user/avator', App.hasBody, App.hasToken, User.update);//上传头像
....

5.第三方服务sms.js讲解

'use strict'

/**
 *  speakeasy 验证码生成器
 **/

const https = require('https')
const querystring = require('querystring')
const Promise = require('bluebird')
const speakeasy = require('speakeasy')

exports.getCode = function() {
  var code = speakeasy.totp({
    secret: 'xjsapp',
    digits: 6
  })

  return code
}

exports.send = function(number, code) {
  return new Promise(function(resolve, reject) {
    if (!number) {
      return reject(new Error('手机号不能为空!'))
    }

    var postData = {
      mobile: number,
      message: '您的验证码是' + code + '。请在页面中提交验证码完成验证。【享健身】'
    }

    var content = querystring.stringify(postData)
    var options = {
      host: 'sms-api.luosimao.com',
      path: '/v1/send.json',
      method: 'POST',
      auth: 'api:key-xxxxxxxxxxxxxxxxxxxxxxxxxx',
      agent: false,
      rejectUnauthorized: false,
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Content-Length': content.length
      }
    }

    var str = ''
    var req = https.request(options, function(res) {
      if (res.statusCode === 404) {
        reject(new Error('短信服务器没有响应'))
        return
      }

      res.setEncoding('utf8')
      res.on('data', function(chunk) {
        str += chunk
      })
      res.on('end', function() {
        var data

        try {
          data = JSON.parse(str)
        } catch (e) {
          reject(e)
        }

        if (data.error === 0) {
          resolve(data)
        } else {
          var errorMap = {
            '-10': '验证信息失败  检查apikey是否和各种中心内的一致,调用传入是否正确',
            '-11': '用户接口被禁用滥发违规内容,验证码被刷等,请联系客服解除',
            '-20': '短信余额不足  进入个人中心购买充值',
            '-30': '短信内容为空  检查调用传入参数:message',
            '-31': '短信内容存在敏感词 接口会同时返回  hit 属性提供敏感词说明,请修改短信内容,更换词语',
            '-32': '短信内容缺少签名信息  短信内容末尾增加签名信息eg.【公司名称】',
            '-33': '短信过长,超过300字(含签名)  调整短信内容或拆分为多条进行发送',
            '-40': '错误的手机号  检查手机号是否正确',
            '-41': '号码在黑名单中 号码因频繁发送或其他原因暂停发送,请联系客服确认',
            '-42': '验证码类短信发送频率过快  前台增加60秒获取限制',
            '-50': '请求发送IP不在白名单内  查看触发短信IP白名单的设'
          }

          reject(new Error(errorMap[data.error]))
        }
      })
    })

    req.write(content)
    req.end()
  })
}

1.引入第三方库speakeasy,该库能随机生成6位数的验证码。

2.这个js封装对螺丝帽服务的短信接口和生成随机的二维码,供业务层user.js使用。

let code = sms.getCode();//获取6位数验证码
...
sms.send(number, code);//发送手机验证码

总结一下:

Koa功能还是很强大的,和MongoDB一起使用完全能应对中小型业务。

骚年们,继续加油吧!

Headshot of Maxi Ferreira

怀着敬畏之心,做好每一件事。