环境准备
1、安装配置node环境
2、安装MySQL数据库
3、安装redis
4、全局安装nodemon
相关文章
基于nodejs使用express 创建web服务器
基于nodejs的web服务器,连接使用redis
基于nodejs的web服务器,连接使用数据库
Github代码仓库地址
一、项目初始化及前期准备
1、初始化项目
npm init -y
2、创建目录结构
根据模块化思想,我创建的目录结构如下,仅供参考,你们可根据个人习惯进行创建
目录结构解析:所有代码放在src之下
index.js:项目入口文件
common/config.js:该文件中存放项目所有配置
common/database.js:该文件存放数据库相关代码
common/middleware.js:该文件存放项目中所有中间件函数
common/redis.js:该文件存放redis相关代码
common/utils.js:该文件存放项目所有公共方法
router/users.js:该文件存放登陆注册等接口的路由
routerHandler/user.j: 该文件存放路由处理函数
.gitignore:该文件为忽略git提交配置,其中node_moudles和package-lock.json需要忽略
3、安装所需所有第三方包
1、配置package.json文件
{
"name": "login_register_server",
"version": "1.0.0",
"description": "",
"main": "src/index.js",
"scripts": {
"serve": "nodemon src/index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"express": "^4.18.2",
"express-jwt": "^8.4.1",
"joi": "^17.9.2",
"jsonwebtoken": "^9.0.0",
"mysql": "^2.18.1",
"redis": "^4.6.7",
"svg-captcha": "^1.4.0",
"uuid": "^9.0.0"
}
}
package.json解析:
"scripts": {"serve": "nodemon src/index.js"}:配置运行代码方式,直接使用npm run serve即可执行nodemon src/index.js启动项目
bcryptjs:用于加密密码和比较明文密码和加密后密码的一致性
cors:解决跨域请求失败的问题
express:主要框架
express-jwt:用于验证token是否过期
joi:方便验证表单数据
jsonwebtoken:生成token
mysql:连接操作数据库
redis:连接操作redis
svg-captcha:生成验证码
uuid:生成唯一用户id
4、创建数据库及用户表
本文案例数据库和用户表名称均为users
二、项目具体实现
1、实现公共模块
1、config.js
module.exports = {
secretKey: 'my_users_secret_key',
expiresIn: 5184000, // token过期时间,单位秒(token过期时间设置为60天)
loginDuration: 86400 // token失效时间,单位秒(根据业务逻辑设置token失效时间)
}
2、database.js,连接数据库
// 导入数据库
const mysql = require('mysql')
// 创建数据库实例并连接数据库
const database = mysql.createPool({
host: '127.0.0.1',
user: '*****',
password: '******',
database: 'users'
})
// 导出数据库
module.exports = database
3、middleware.js,实现全局和局部中间件
const Joi = require('joi')
const { secretKey } = require('../common/config')
const jwt = require('jsonwebtoken')
const { redisGet } = require('../common/redis')
// 全局中间件
// 封装res.send方法
exports.resSendMiddleware = (req, res, next) => {
res.commonResSend = (status, message, data) => {
res.send({
status,
message,
data: data ? data : null
})
}
next()
}
// token解析方法
exports.analyzeToken = async (req, res, next) => {
const headerToken = req.headers['authorization']
if (headerToken) {
const token = headerToken.replace('Bearer ', '')
const userInfo = jwt.verify(token, secretKey)
// 判断是否是有效未超时的token
const isEfficientToken = await redisGet(userInfo.userId)
// token有效,将用户信息存入req
if (isEfficientToken) {
req.userInfo = userInfo
} else {
return res.commonResSend(401, '身份认证失败')
}
}
next()
}
// 全局错误中间件
exports.handleError = (err, req, res, next) => {
if (err.name === 'UnauthorizedError') {
// JWT 认证失败
res.commonResSend(401, '身份认证失败')
}
}
// 路由中间件
// 验证注册表单
exports.verifyRegister = (req, res, next) => {
const userInfo = req.body
const schema = Joi.object({
userName: Joi.string().alphanum().min(3).max(32).required(),
password: Joi.string().alphanum().min(6).max(32).required(),
nickName: Joi.string().min(1).max(64),
email: Joi.string().email(),
phoneNumber: Joi.string().min(6).max(11).pattern(/^\d+$/),
avater: Joi.string().dataUri()
})
const result = schema.validate(userInfo)
if (result.error) {
return res.commonResSend(1, result.error.details)
} else {
next()
}
}
4、redis.js,连接redis,并封装常用方法
const redis = require('redis')
// 创建redis客户端实例
const redisClient = redis.createClient()
// 连接redis
redisClient.connect()
// Redis 客户端监听连接事件
redisClient.on('connect', () => {
console.log('Connected to Redis server')
})
// Redis 客户端监听错误事件
redisClient.on('error', (error) => {
console.error('Redis error:', error)
})
// 封装常用redis方法
// 设置值
const redisSet = (key, value) => {
return new Promise((resolve, reject) => {
if (key === undefined || !key) {
reject("key不能为空或undefined")
return
} else if (typeof key !== 'string') {
reject("key必须为字符串")
return
} else if (value === undefined || !value || typeof value === 'boolean') {
reject("value不能为空、undefined或布尔值")
return
} else if (typeof value === "object") {
value = JSON.stringify(value)
}
try {
redisClient.set(key, value).then((replay) => {
if (replay === 'OK') {
resolve(true)
} else {
resolve(false)
}
}).catch((error) => {
reject(error)
})
} catch (error) {
reject(error)
}
})
}
// 设置过期时间
const redisSetTimeout = (key, timeout) => {
return new Promise((resolve, reject) => {
if (key === undefined || !key) {
reject("key不能为空或undefined")
return
} else if (typeof key !== 'string') {
reject("key必须为字符串")
return
} else if (key === undefined || !key) {
reject("timeout不能为空、undefined或0")
return
} else if (typeof timeout !== 'number' || timeout <= 0) {
reject("timeout必须为大于零的数字")
return
}
try {
redisClient.expire(key, Math.ceil(timeout)).then((replay) => {
resolve(replay)
}).catch((error) => {
reject(error)
})
} catch (error) {
reject(error)
}
})
}
// 获取值
const redisGet = (key) => {
return new Promise((resolve, reject) => {
if (key === undefined || !key) {
reject("key不能为空或undefined")
return
} else if (typeof key !== 'string') {
reject("key必须为字符串")
return
}
try {
redisClient.get(key).then((value) => {
// 利用 JSON.parse(string) 报错返回正确的数据格式
try {
resolve(JSON.parse(value))
} catch (error) {
resolve(value)
}
}).catch((error) => {
reject(error)
})
} catch (error) {
reject(error)
}
})
}
// 获取过期时间
const redisGetTimeout = (key) => {
return new Promise((resolve, reject) => {
if (key === undefined || !key) {
reject("key不能为空或undefined")
return
} else if (typeof key !== 'string') {
reject("key必须为字符串")
return
}
try {
redisClient.ttl(key).then((timeout) => {
resolve(timeout)
}).catch((error) => {
reject(error)
})
} catch (error) {
reject(error)
}
})
}
// 删除值
const redisDelete = (key) => {
return new Promise((resolve, reject) => {
if (key === undefined || !key) {
reject("key不能为空或undefined")
return
} else if (typeof key !== 'string') {
reject("key必须为字符串")
return
}
try {
redisClient.del(key).then((replay) => {
if (replay) {
resolve(true)
} else {
resolve(false)
}
}).catch((error) => {
reject(error)
})
} catch (error) {
reject(error)
}
})
}
// 设置指定数据库,传入索引值,0-15
const redisSetDb = (index) => {
return new Promise((resolve, reject) => {
if (index < 0 || index > 15) {
reject("请输入正确数据库索引值0-15")
return
}
try {
redisClient.select(index).then((replay) => {
resolve(true)
}).catch((error) => {
reject(error)
})
} catch (error) {
reject(error)
}
})
}
// 恢复默认数据库
const redisResetDb = () => {
return new Promise((resolve, reject) => {
try {
redisClient.select(0).then((replay) => {
resolve(true)
}).catch((error) => {
reject(error)
})
} catch (error) {
reject(error)
}
})
}
module.exports = {
redisSet,
redisSetTimeout,
redisGet,
redisGetTimeout,
redisDelete,
redisSetDb,
redisResetDb
}
5、utils.js
const svgCaptcha = require('svg-captcha')
// 创建验证码
exports.createCaptcha = () => {
const svgCaptchaOption = {
size: 4,
noise: 3,
color: true,
background: '#666666'
}
return svgCaptcha.create(svgCaptchaOption)
}
2、创建路由处理函数模块,并导出
routerHandler/user.js
const database = require('../common/database')
const bcrypt = require('bcryptjs')
const { v4: uuid } = require('uuid')
const jwt = require('jsonwebtoken')
const { secretKey, expiresIn, loginDuration } = require('../common/config')
const {
redisSet,
redisSetTimeout,
redisGet,
redisDelete
} = require('../common/redis')
const { createCaptcha } = require('../common/utils')
/**
* 注册接口处理函数
* @param {
* userName: 必传 string,
* password: 必传 string,
* nickName: string,
* email: string,
* phoneNumber: string,
* avater: base64
* }
*/
exports.register = (req, res) => {
const userInfo = req.body
const selectSql = 'select * from users where userName=?'
database.query(selectSql, [userInfo.userName], (err, result) => {
if (err) { return res.commonResSend(1, err.message) }
if (result.length > 0) {
res.commonResSend(1, '用户名已存在')
} else {
// 使用bcrypt加密密码
userInfo.password = bcrypt.hashSync(userInfo.password, 10)
// 使用uuid生成唯一id
userInfo.userId = uuid().replace(/-/g, '')
// 插入数据
const insertSql = 'insert into users set ?'
database.query(insertSql, userInfo, (err, result) => {
if (err) { return res.commonResSend(1, err.message) }
res.commonResSend(0, '注册成功')
})
}
})
}
/**
* 登录接口处理函数
* @param {
* userName: 必传 string,
* password: 必传 string
* }
*/
exports.login = (req, res) => {
const { userName, password, captcha } = req.body
if (!userName || !password) {
res.commonResSend(1, '账号密码不能为空')
} else {
const selectSql = 'select * from users where userName=?'
database.query(selectSql, [userName], async (err, result) => {
if (err) { return res.commonResSend(1, err.message) }
if (result.length > 0) {
const userInfo = result[0]
let failNumber = 0
// 获取当前用户是否输错过密码
const redisFailNumber = await redisGet(userName + 'failNumber')
if (redisFailNumber) failNumber = redisFailNumber
const redisCaptcha = await redisGet(userName + 'captcha')
// 密码输入错误超过五次,一小时内不允许再次尝试
if (failNumber === 5) {
return res.commonResSend(1, '密码输入错误超过五次,请一小时后再试')
} else if (failNumber >= 1 && failNumber < 5 // 验证码错误时生成新的验证码并返回
&& (!captcha || !redisCaptcha || captcha.toLowerCase() !== redisCaptcha.toLowerCase())) {
const { text, data } = createCaptcha()
await redisSet(userName + 'captcha', text)
await redisSetTimeout(userName + 'captcha', 60)
return res.commonResSend(0, '验证码错误', {
captcha: data,
failNumber
})
}
// 对比密码
const compareResult = bcrypt.compareSync(password, userInfo.password)
// 账号密码正确,登陆成功
if (compareResult) {
const existToken = await redisGet(userInfo.userId)
let token = ''
// 存在有效token,返回该token
if (existToken) {
token = existToken
} else {
// 生成token并存入redis
const payload = { ...userInfo, password: '', avater: '' }
token = jwt.sign(payload, secretKey, { expiresIn: expiresIn + 's' })
await redisSet(userInfo.userId, token)
await redisSetTimeout(userInfo.userId, loginDuration)
}
// 登陆成功删除redis中的密码输入错误次数和验证码
await redisDelete(userName + 'failNumber')
await redisDelete(userName + 'captcha')
//TODO apiFox会自动加上Bearer,接口测试使用该代码,与前端对接联调加上Bearer
// res.commonResSend(0, '登录成功', { token: 'Bearer ' + token })
res.commonResSend(0, '登录成功', { token })
} else {
// 密码输入错误,生成验证码返回客户端,redis储存验证码和失败次数,多次失败次数累加
const { text, data } = createCaptcha()
await redisSet(userName + 'failNumber', failNumber + 1)
await redisSetTimeout(userName + 'failNumber', 3600)
await redisSet(userName + 'captcha', text)
await redisSetTimeout(userName + 'captcha', 60)
res.commonResSend(0, '账号密码错误', {
captcha: data,
failNumber: failNumber + 1
})
}
} else {
res.commonResSend(1, '该用户不存在')
}
})
}
}
/**
* 刷新验证码处理函数
* @param {
* userName: 必传 string
* }
*/
exports.refreshCaptcha = async (req, res) => {
const { userName } = req.body
const { text, data } = createCaptcha()
await redisSet(userName + 'captcha', text)
await redisSetTimeout(userName + 'captcha', 60)
res.commonResSend(0, '验证码刷新成功', {
captcha: data
})
}
/**
* 修改密码处理函数
* @param {
* userName: 必传 string,
* password: 必传 string,
* newPassword: 必传 string,
* confirmPassword: 必传 string
* }
*/
exports.revisePassword = (req, res) => {
const { userName, password, newPassword, confirmPassword } = req.body
if (!userName || !password) {
res.commonResSend(1, '账号密码不能为空')
} else if (!newPassword) {
res.commonResSend(1, '新密码不能为空')
} else if (!confirmPassword) {
res.commonResSend(1, '确认新密码不能为空')
} else if (newPassword !== confirmPassword) {
res.commonResSend(1, '新密码和确认新密码不一致')
} else {
const selectSql = 'select * from users where userName=?'
database.query(selectSql, [userName], async (err, result) => {
if (err) { return res.commonResSend(1, err.message) }
if (result.length > 0) {
const userInfo = result[0]
const compareResult = bcrypt.compareSync(password, userInfo.password)
if (compareResult) {
newDatabasePassword = bcrypt.hashSync(newPassword, 10)
const sql = `update users set password=? where userId='${userInfo.userId}'`
database.query(sql, [newDatabasePassword], (err, result) => {
if (err) { return res.commonResSend(1, err.message) }
res.commonResSend(0, '修改密码成功')
})
} else {
res.commonResSend(1, '账号密码错误')
}
} else {
res.commonResSend(1, '该用户不存在')
}
})
}
}
// 退出登录
exports.logout = async (req, res) => {
const userInfo = req.userInfo
await redisDelete(userInfo.userId)
res.commonResSend(0, '登出成功')
}
3、创建路由模块,并导入处理函数
router/user.js
const express = require('express')
const router = express.Router()
const {
register,
login,
revisePassword,
logout,
refreshCaptcha
} = require('../routerHandler/user')
const { verifyRegister } = require('../common/middleware')
router.post('/register', verifyRegister, register)
router.post('/login', login)
router.post('/refreshCaptcha', refreshCaptcha)
router.post('/revisePassword', revisePassword)
router.post('api/logout', logout)
module.exports = router
4、导入所需模块,启动服务
服务运行在3000端口
const express = require('express')
const cors = require('cors')
const userRouter = require('./router/user')
const app = express()
const { expressjwt } = require('express-jwt')
const {
resSendMiddleware,
handleError,
analyzeToken
} = require('./common/middleware')
const { secretKey } = require('./common/config')
// 处理跨域中间件
app.use(cors())
// 处理解析表单数据中间件
app.use(express.urlencoded({ extended: false }))
// 封装res.send()方法中间件
app.use(resSendMiddleware)
// 验证token中间件
app.use(expressjwt({
secret: secretKey,
algorithms: ["HS256"]
}).unless({
path: ['/login', '/register', '/revisePassword', '/refreshCaptcha']
}))
// 解析token中间件
app.use(analyzeToken)
// 注册路由
app.use(userRouter)
// 注册全局错误中间件
app.use(handleError)
// 启动服务
app.listen(3000, () => {
console.log('Server is running on port 3000')
})
三、接口测试
接口测试可使用apifox进行测试,下面是登录接口测试结果案例