登录注册在有账户的系统中都是必不可少的基础功能,本文主要记录了web登录注册的前后端成熟的解决方案。
一、介绍
1. 主要功能
- 用户信息的数据库存储结构
- 前端密码加密传输
- 后端密码加密存储及认证
- 前端登录态保存
- 解析登录态的中间件
- 图片验证码
- 邮件验证码
2. 技术栈
- 前端:vue + antd-ui + axios
- 后端:
nodejs + koa + mongodb
3. 准备
- nodejs入门:Node.js 教程
- Koa入门:Koa 框架教程
- MongoDB入门: MongoDB 教程
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
};
五、注册
前端注册表单页面主要由用户名、密码、图片验证码组成,还可以在这里加上手机号或邮箱的绑定。
- 前端将密码简单hash加密,避免密码明文传输;
- 为每个用户生成随机的盐,并且存储在单独的表中;
- 使用
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 = '用户注册成功';
}
六、登录
- 步骤:
- 前端将密码简单hash加密,避免密码明文传输;
- 根据用户的id在盐表中查询注册时生成的盐
salt
; - 根据
salt
和用户登录密码使用与注册时同样的方式加密,生成hash密码; - 使用
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;