koa+mongondb实现用户登录注册模块

2,504 阅读4分钟

登录注册在有账户的系统中都是必不可少的基础功能,本文主要记录了web登录注册的前后端成熟的解决方案。

一、介绍

1. 主要功能

  • 用户信息的数据库存储结构
  • 前端密码加密传输
  • 后端密码加密存储及认证
  • 前端登录态保存
  • 解析登录态的中间件
  • 图片验证码
  • 邮件验证码

2. 技术栈

  • 前端:vue + antd-ui + axios
  • 后端:nodejs + koa + mongodb

3. 准备

4. 参考文章

二、步骤

注册表单页面 ➜ 注册POST请求 ➜ 加密 ➜ 数据库存盐 ➜ 数据库存用户名密码

登录表单页面 ➜ 登录POST请求 ➜ 查询盐 ➜ 相同方法加密 ➜ 查询用户 ➜ 存session ➜ 设置登录态

三、数据库表结构设计

1. 用户表

'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
// 用户表
const userSchema = new Schema({
    // 用户名
    username: {
        type: String,
        default: ''
    },
    // 密码
    password: {
        type: String,
        default: ''
    },
    // 昵称
    nickname: {
        type: String,
        default: ''
    },
    // 头像
    headImg: {
        type: String,
        default: ''
    },
    // 邮箱
    email: {
        type: String,
        default: ''
    }
});

const UserModel = mongoose.model('User', userSchema);

module.exports = UserModel;

2. 会话表

'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;

const sessionSchema = new Schema({
    // 关联字段 - 用户id
    userId: {
        type: mongoose.Schema.Types.ObjectId,
        ref: 'User'
    },
    // 会话时间
    createTime: {
        type: Date,
        default: new Date()
    }
});

const SessionModel = mongoose.model('Session', sessionSchema);

module.exports = SessionModel;

3. 用户密码加密盐表

'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;

const saltSchema = new Schema({
    // 关联字段 - 用户id
    userId: {
        type: mongoose.Schema.Types.ObjectId,
        ref: 'User'
    },
    // 盐
    salt: {
        type: String,
        default: ''
    },
    // 会话时间
    createTime: {
        type: Date,
        default: new Date()
    }
});

const SaltModel = mongoose.model('Salt', saltSchema);

module.exports = SaltModel;

四、密码加密

因为md5等单项加密算法已过时,可以通过彩虹表破解,因此这里选择bcrypt加密,将加密封装成两个方法供加密和验证使用。 bcrypt.js

const bcrypt = require('bcrypt'); // bcrypt加密
/**
 * 密码加密
 **@param {String} pass
 **@return {Object} {hash, salt}
 */
async function encrypt (pass) {
    // 生成salt的迭代次数
    const saltRounds = 10;
    // 随机生成salt(盐)
    const salt = await bcrypt.genSalt(saltRounds);
    // 获取hash值
    const hash = await bcrypt.hash(pass, salt);
    return {hash, salt};
}

/**
 * 密码带盐加密
 **@param {String} pass
 **@return {String} hashPass
 */
async function encryptWithSalt (pass, salt) {
    return await bcrypt.hash(pass, salt);
}
module.exports = {
    encrypt,
    encryptWithSalt
};

五、注册

前端注册表单页面主要由用户名、密码、图片验证码组成,还可以在这里加上手机号或邮箱的绑定。

  1. 前端将密码简单hash加密,避免密码明文传输;
  2. 为每个用户生成随机的盐,并且存储在单独的表中;
  3. 使用bcrypt加密,生成hash密码存储起来;
  • 注册代码如下:
const ObjectId = require('mongodb').ObjectId;
const User = require('../models/user');
const Salt = require('../models/salt');
const {encrypt, encryptWithSalt} = require('../utils/bcrypt.js');

/**
 * 用户注册:POST
 * @param {Object} ctx 
 * @param {Object} next 
 */
async function register (ctx) {
    const data = ctx.request.body;
    const criypt = await encrypt(data.password);
    const userObj = {
        username: data.username,
        password: criypt.hash,
        nickname: data.username
    };
    const user = new User(userObj);
    // 把盐存进数据库
    const saltObj = {
        salt: criypt.salt,
        userId: ObjectId(user._id),
        createTime: new Date()
    };
    const salt = new Salt(saltObj);
    await salt.save();
    await user.save();
    ctx.body = '用户注册成功';
}

六、登录

  • 步骤:
  1. 前端将密码简单hash加密,避免密码明文传输;
  2. 根据用户的id在盐表中查询注册时生成的盐salt
  3. 根据salt和用户登录密码使用与注册时同样的方式加密,生成hash密码;
  4. 使用hash密码用户id用户表中搜索,如果查询到结果说明密码正确,查询不到说明密码错误;

注意: 这里查询用户是否存在可单独使用用户id来查询,这么做的目的是尽量减少用户密码在服务器内存中加载,减少暴露密码的风险。

  • 代码如下:
const ObjectId = require('mongodb').ObjectId;
const User = require('../models/user');
const Salt = require('../models/salt');
const {encryptWithSalt} = require('../utils/bcrypt.js');

/**
 * 用户登录:POST
 * @param {Object} ctx 
 * @param {Object} next 
 */
async function login (ctx) {
    const data = ctx.request.body;
    const user = await User.findOne({username: data.username});
    const saltObj = await Salt.findOne({userId: user._id});
    const hashPassword = await encryptWithSalt(data.password, saltObj.salt);
    const user = await User.findOne({_id: user._id, password: hashPassword});
    if (!user) {
	ctx.body = '密码错误';
        return;
    }
    const sessionObj = {userId: res._id, createTime: new Date()};
    const session = new Session(sessionObj);
    await session.save();
    ctx.cookies.set('session_id', session._id);
    ctx.body = '用户登录成功';
}

七、邮箱验证码

'use strict';
const nodemailer = require('nodemailer');
const mailOptions = {
    service: 'qq',
    port: 465,
    from: '123@qq.com', // sender address
    pass: 'blablablablabla'
};
const transporter = nodemailer.createTransport({
    service: mailOptions.service,
    port: mailOptions.port, // SMTP 端口
    secureConnection: true, // 使用了 SSL
    auth: {
        user: mailOptions.from,
        pass: mailOptions.pass // 这里密码不是qq密码,是你设置的smtp授权码
    }
});

/**
 * 发送邮件
 * @param {String} from 发件人,非必填
 * @param {String} to 收件人
 * @param {String} subject 发送的主题
 * @param {String} html 发送的html内容
 */
async function sendMail({from, to, subject, html}) {
    return new Promise((resolve, reject) => {
        const option = {from: from || mailOptions.from, to, subject, html};
        // send mail with defined transport object
        transporter.sendMail(option, (error, info) => {
            if (error) {
                reject(error);
                return console.log(error);
            }
            resolve(info);
        });
    });
}
module.exports = sendMail;

const sendMail = require('../utils/email.js');
await sendMail({
    to: 'your_email@qq.com',
    subject: '登录验证邮件',
    html: `<p>验证码: <strong>${emailVerifyCode}</strong></p>`
}).catch(err => {
    ctx.body = '发送邮件失败,邮箱不合法';
});
/**
 * 发送验证码邮件
 * @param {Object} ctx 
 * @param {Object} next 
 */
async function sendVerifyCodeEmail(ctx) {
    const params = ctx.request.body;
    const email = params.email;
    if (!email || !verify('email', email)) {
        ctx.$error('发送邮件失败,邮箱不合法');
        return;
    }
    const emailVerifyCode = [1, 2, 3, 4, 5, 6].map(() => parseInt(Math.random() * 10)).join('');
    ctx.session.emailVerifyCode = emailVerifyCode + '|' + Date.now();
    await sendMail({
        to: email,
        subject: '博客验证',
        html: `<h1>博客验证</h1><p>验证码: <strong>${emailVerifyCode}</strong></p><p>Tip: 如果您并无此操作请忽略此邮件</p>`
    }).catch(err => {
        ctx.body = '发送邮件失败,邮箱不合法';
    });
    ctx.body = '验证码邮件发送成功';
}

  • 验证码使用
/**
 * 检查验证码
 * @param {Object} ctx 
 * @param {Object} body 
 */
async function checkEmailVerifyCode (ctx) {
    const emailVerifyCode = ctx.session.emailVerifyCode;
    const code = emailVerifyCode.split('|')[0];
    const time = Number(emailVerifyCode.split('|')[1], 10);
    if (!ctx.session.emailVerifyCode || code !== verifyCode) {
        return '验证码错误';
    }
    if (Date.now() - time > 60000) {
        return '验证码已过期,请重新获取';
    }
    return false;
}

八、图片验证码

使用svg-captcha模块,随机生成数字及字母组成的svg图片验证码。

'use strict';
const svgCaptcha = require('svg-captcha');// svg图片验证码
/**
 * 获取图片验证码
 * @param {Object} ctx 
 * @param {Object} body 
 */
async function getImgVeriCode (ctx) {
    const params = ctx.request.body;
    const {size, fontSize, noise, width, height, background} = params;
    const captcha = svgCaptcha.create({
        size: size || 4,
        fontSize: fontSize || 50,
        noise: noise || 3,
        width: width || 120,
        height: height || 34,
        background: background || '#eee'
    });
    ctx.session.verifyCode = captcha.text;
    ctx.body = JSON.stringify({svgCode: captcha.data});
}
  • 验证码使用
/**
 * 检查验证码
 * @param {Object} ctx 
 * @param {Object} body 
 */
async function checkEmailVerifyCode (ctx) {
    const params = ctx.request.body;
    const code = ctx.session.verifyCode.toLocaleLowerCase();
    const verifyCode = params.verifyCode.toLocaleLowerCase();
    if (!code || code !== verifyCode) {
        return '验证码错误';
    }
    return false;
}

九、解析登录态获取登录用户的中间件

const User = require('../models/user');
const Session = require('../models/session');
/**
 * 用户登录信息中间件
 */
module.exports = function () {
    return async function (ctx, next) {
        const sessionId = ctx.cookies.get('session_id');
        if (!sessionId) {
            ctx.loginUser = {code: 1, message: '您还未登录'};
            await next();
            return null;
        }
        const session = await Session.findOne({_id: sessionId});
        if (!session) {
            ctx.loginUser = {code: 2, message: '登录态已过期,请重新登录'};
            await next();
            return null;
        }
        let user = await User.findOne({_id: session.userId});
        if (!user) {
            ctx.loginUser = {code: 3, message: '用户不存在'};
            await next();
            return null;
        }
        user = ctx.$tools.deepClone(user);
        delete user.password;
        ctx.loginUser = {code: 0, data: user, message: '用户不存在'};
        await next();
        return ctx.loginUser;
    };
};

  • 使用
const loginUser = require('./src/middleware/loginUser');
const Koa = require('koa');
const app = new Koa();
app.use(loginUser());

// 使用
if (ctx.loginUser.code !== 0) {
    ctx.body = ctx.loginUser.message;
    return;
}
const user = ctx.loginUser.data;