From ddcf200de280ba7ac97050d73e8a7fecd332e3b4 Mon Sep 17 00:00:00 2001 From: lik Date: Fri, 12 Jun 2026 15:24:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E4=BA=86=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- handler/users.js | 362 ++++++++++++++++++---------------------- middleware/auth.js | 83 +++++++++ middleware/ratelimit.js | 51 ++++++ models/schema/user.js | 7 +- package-lock.json | 108 ++++++++++++ package.json | 4 +- routes/index.js | 10 +- test/api.test.js | 265 +++++++++++++++++++++++++++++ test/auth.test.js | 85 ++++++++++ test/crypto.test.js | 51 ++++++ test/ratelimit.test.js | 49 ++++++ utils/crypto.js | 36 ++++ 12 files changed, 904 insertions(+), 207 deletions(-) create mode 100644 middleware/auth.js create mode 100644 middleware/ratelimit.js create mode 100644 test/api.test.js create mode 100644 test/auth.test.js create mode 100644 test/crypto.test.js create mode 100644 test/ratelimit.test.js create mode 100644 utils/crypto.js diff --git a/handler/users.js b/handler/users.js index 8b7eddb..0d13c77 100644 --- a/handler/users.js +++ b/handler/users.js @@ -1,42 +1,79 @@ +import Joi from 'joi'; import { DBModel } from "../models/index.js"; import ResponseUtil from "../utils/api_response.js"; +import { hashPassword, verifyPassword } from "../utils/crypto.js"; +import { sanitizeUser, extractToken } from "../middleware/auth.js"; import config from "../conf.json" with { type: "json" }; -class HandlerUser { - constructor() { - } +// 输入校验 schema +const registerSchema = Joi.object({ + userInfo: Joi.object({ + profile: Joi.object({ + mobile: Joi.string().required().messages({ + 'any.required': '缺少手机号', + 'string.empty': '手机号不能为空' + }), + name: Joi.string().allow(''), + }).required(), + security: Joi.object({ + passwd: Joi.string().min(6).required().messages({ + 'any.required': '缺少密码', + 'string.min': '密码至少6位' + }), + }).required(), + location: Joi.object({ + province: Joi.string().allow(''), + city: Joi.string().allow(''), + district: Joi.string().allow(''), + }).optional(), + addresses: Joi.array().optional(), + app: Joi.object().optional(), + }).required(), +}); +const signinSchema = Joi.object({ + mobile: Joi.string().required().messages({ + 'any.required': '缺少手机号', + 'string.empty': '手机号不能为空' + }), + passwd: Joi.string().required().messages({ + 'any.required': '缺少密码', + 'string.empty': '密码不能为空' + }), +}); + +const wxSigninSchema = Joi.object({ + code: Joi.string().required().messages({ 'any.required': '缺少微信登录凭证 code' }), + appId: Joi.string().required().messages({ 'any.required': '缺少 appId' }), + phoneNumber: Joi.string().allow('', null), + name: Joi.string().allow('', null), +}); + +const wxGetPhoneSchema = Joi.object({ + code: Joi.string().required().messages({ 'any.required': '缺少手机号凭证 code' }), + appId: Joi.string().required().messages({ 'any.required': '缺少 appId' }), +}); + +class HandlerUser { // 生成 token async genToken(uid) { const crypto = await import("crypto"); - const hash = crypto.createHash("md5"); + const hash = crypto.createHash("sha256"); hash.update(uid + Date.now() + Math.random()); return hash.digest("hex"); } - checkUserValid(user) { - const isTokenValid = user.security.token && - user.security.tokenExpiry && - new Date() < user.security.tokenExpiry; - return isTokenValid; - } - // 用户注册 async register(ctx) { - try { - const { userInfo } = ctx.request.body; - if (!userInfo) { - return ResponseUtil.badRequest(ctx, "缺少用户信息"); - } + const { error, value } = registerSchema.validate(ctx.request.body, { abortEarly: false }); + if (error) { + return ResponseUtil.badRequest(ctx, error.details[0].message); + } - const mobile = userInfo.profile?.mobile; - const passwd = userInfo.security?.passwd; - if (!mobile) { - return ResponseUtil.badRequest(ctx, "缺少手机号"); - } - if (!passwd) { - return ResponseUtil.badRequest(ctx, "缺少密码"); - } + try { + const { userInfo } = value; + const mobile = userInfo.profile.mobile; + const passwd = userInfo.security.passwd; // 检查手机号是否已注册 const existingUser = await DBModel.User.findOne({ "profile.mobile": mobile }); @@ -44,21 +81,13 @@ class HandlerUser { return ResponseUtil.error(ctx, "手机号已注册", null, 409); } - // 生成随机盐值 - const crypto = await import("crypto"); - const salt = crypto.randomBytes(8).toString("hex"); + // bcrypt 加密密码 + const encryptedPasswd = await hashPassword(passwd); - // MD5 加密密码 - const hash = crypto.createHash("md5"); - hash.update(passwd + salt); - const encryptedPasswd = hash.digest("hex"); - - // 构建新用户,结构同 UserSchema const newUser = { profile: userInfo.profile, security: { passwd: encryptedPasswd, - passwdSalt: salt, }, location: { province: userInfo.location?.province || '', @@ -67,8 +96,7 @@ class HandlerUser { }, addresses: userInfo.addresses || [], social: { - wechat: { - } + wechat: {} }, status: { account: "normal", @@ -87,12 +115,7 @@ class HandlerUser { user.security.tokenExpiry = new Date(Date.now() + 15 * 24 * 60 * 60 * 1000); await user.save(); - // 安全起见删除密码相关字段 - const safeUser = user.toObject(); - delete safeUser.security?.passwd; - delete safeUser.security?.passwdSalt; - - return ResponseUtil.success(ctx, { user: safeUser }, "注册成功"); + return ResponseUtil.success(ctx, { user: sanitizeUser(user) }, "注册成功"); } catch (err) { return ResponseUtil.internalError(ctx, err.message); } @@ -100,14 +123,13 @@ class HandlerUser { // 手机号码登录 async signin(ctx) { + const { error, value } = signinSchema.validate(ctx.request.body, { abortEarly: false }); + if (error) { + return ResponseUtil.badRequest(ctx, error.details[0].message); + } + try { - const { mobile, passwd } = ctx.request.body; - if (!mobile) { - return ResponseUtil.badRequest(ctx, "缺少手机号"); - } - if (!passwd) { - return ResponseUtil.badRequest(ctx, "缺少密码"); - } + const { mobile, passwd } = value; // 查找用户 let user = await DBModel.User.findOne({ "profile.mobile": mobile }); @@ -115,39 +137,37 @@ class HandlerUser { return ResponseUtil.unauthorized(ctx, "用户不存在"); } - // 校验密码(MD5 加密比对) - const crypto = await import("crypto"); - const hash = crypto.createHash("md5"); - hash.update(passwd + (user.security.passwdSalt || "")); - const encryptedPasswd = hash.digest("hex"); + // 校验密码(支持 bcrypt 和 MD5 渐进式迁移) + const { valid, needsUpgrade } = await verifyPassword( + passwd, user.security.passwd, user.security.passwdSalt + ); - if (user.security.passwd !== encryptedPasswd) { + if (!valid) { + // 记录失败登录次数 + await DBModel.User.incrementFailedLoginAttempts(user._id); return ResponseUtil.unauthorized(ctx, "密码错误"); } - // 检查账户状态 - if (user.status.account === "lock") { - return ResponseUtil.forbidden(ctx, "账户已被锁定"); + // 渐进式迁移:如果是旧 MD5 密码,登录时升级为 bcrypt + if (needsUpgrade) { + user.security.passwd = await hashPassword(passwd); + user.security.passwdSalt = undefined; + } + + // 重置失败登录次数 + if (user.security.failedLoginAttempts > 0) { + await DBModel.User.resetFailedLoginAttempts(user._id); } // 生成/更新 token - const isTokenValid = user.security.token && - user.security.tokenExpiry && - new Date() < user.security.tokenExpiry; - - if (!isTokenValid) { - const token = await this.genToken(user._id.toString()); - user.security.token = token; - } + const token = await this.genToken(user._id.toString()); + user.security.token = token; user.security.tokenExpiry = new Date(Date.now() + 15 * 24 * 60 * 60 * 1000); + user.security.lastLoginAt = new Date(); + user.security.lastLoginIp = ctx.ip || ctx.request.ip; await user.save(); - // 安全起见删除密码相关字段 - const safeUser = user.toObject(); - delete safeUser.security?.passwd; - delete safeUser.security?.passwdSalt; - - return ResponseUtil.success(ctx, { user: safeUser }, "登录成功"); + return ResponseUtil.success(ctx, { user: sanitizeUser(user) }, "登录成功"); } catch (err) { return ResponseUtil.internalError(ctx, err.message); } @@ -155,11 +175,7 @@ class HandlerUser { // 退出登录 async signout(ctx) { - const token = ctx.request.body?.token - || ctx.request.query?.token - || ctx.header?.authorization - || ctx.header?.token; - + const token = extractToken(ctx); if (!token) { return ResponseUtil.badRequest(ctx, "缺少 token"); } @@ -176,84 +192,51 @@ class HandlerUser { // 获取用户信息 async userInfo(ctx) { - try { - const { token, userId } = ctx.request.body; - if (!token && !userId) { - return ResponseUtil.badRequest(ctx, "缺少 token 或 userId"); - } - - let user = null; - if (token) { - user = await DBModel.User.findOne({ "security.token": token }); - } - else { - user = await DBModel.User.findOne({ "_id": userId }); - } - - if (!user) { - return ResponseUtil.unauthorized(ctx, "用户未登录或 token 无效"); - } - - const isTokenValid = user.security.token && - user.security.tokenExpiry && - new Date() < user.security.tokenExpiry; - if (!isTokenValid) { - return ResponseUtil.unauthorized(ctx, "登录已过期,请重新登录"); - } - - // 安全起见删除密码相关字段 - delete user.security.passwd; - delete user.security.passwdSalt; - - return ResponseUtil.success(ctx, { user }, "获取用户信息成功"); - } catch (err) { - return ResponseUtil.internalError(ctx, err.message); + const token = extractToken(ctx); + if (!token) { + return ResponseUtil.badRequest(ctx, "缺少 token"); } + + const user = await DBModel.User.findOne({ "security.token": token }); + if (!user) { + return ResponseUtil.unauthorized(ctx, "用户未登录或 token 无效"); + } + + if (user.security.tokenExpiry && new Date() > user.security.tokenExpiry) { + return ResponseUtil.unauthorized(ctx, "登录已过期,请重新登录"); + } + + return ResponseUtil.success(ctx, { user: sanitizeUser(user) }, "获取用户信息成功"); } - // update user + // 更新用户信息 async updateUser(ctx) { + const token = extractToken(ctx); + if (!token) { + return ResponseUtil.badRequest(ctx, "缺少 token"); + } + + const user = await DBModel.User.findOne({ "security.token": token }); + if (!user) { + return ResponseUtil.unauthorized(ctx, "用户未登录或 token 无效"); + } + + if (user.security.tokenExpiry && new Date() > user.security.tokenExpiry) { + return ResponseUtil.unauthorized(ctx, "登录已过期,请重新登录"); + } + try { const userInfo = ctx.request.body; if (!userInfo) { return ResponseUtil.badRequest(ctx, "缺少用户信息"); } - // 从 token 获取当前用户 - const token = ctx.request.body?.token - || ctx.request.query?.token - || ctx.header?.authorization - || ctx.header?.token; - - if (!token) { - return ResponseUtil.badRequest(ctx, "缺少 token"); - } - - const user = await DBModel.User.findOne({ "security.token": token }); - if (!user) { - return ResponseUtil.unauthorized(ctx, "用户未登录或 token 无效"); - } - - // 检查 token 是否过期 - if (user.security.tokenExpiry && new Date() > user.security.tokenExpiry) { - return ResponseUtil.unauthorized(ctx, "登录已过期,请重新登录"); - } - - const updatedUser = await DBModel.User.updateFromUserInfo( - user._id, - userInfo - ); - + const updatedUser = await DBModel.User.updateFromUserInfo(user._id, userInfo); if (!updatedUser) { return ResponseUtil.internalError(ctx, "更新用户失败"); } - // 安全起见删除密码相关字段 - const safeUser = updatedUser.toObject(); - delete safeUser.security?.passwd; - delete safeUser.security?.passwdSalt; - - return ResponseUtil.success(ctx, { user: safeUser }, "更新成功"); + return ResponseUtil.success(ctx, { user: sanitizeUser(updatedUser) }, "更新成功"); } catch (err) { return ResponseUtil.internalError(ctx, err.message); } @@ -261,43 +244,33 @@ class HandlerUser { // 获取用户列表 async userList(ctx) { + const token = extractToken(ctx); + if (!token) { + return ResponseUtil.badRequest(ctx, "缺少 token"); + } + + const user = await DBModel.User.findOne({ "security.token": token }); + if (!user) { + return ResponseUtil.unauthorized(ctx, "用户未登录或 token 无效"); + } + + if (!('wxapp-escort-admin' in user.app)) { + return ResponseUtil.unauthorized(ctx, "用户无管理员权限"); + } + + if (user.security.tokenExpiry && new Date() > user.security.tokenExpiry) { + return ResponseUtil.unauthorized(ctx, "登录已过期,请重新登录"); + } + try { const { page = 1, pageSize = 100 } = ctx.request.body; - // 从 token 获取当前用户 - const token = ctx.request.body?.token - || ctx.request.query?.token - || ctx.header?.authorization - || ctx.header?.token; - // 通过token获取用户 - const user = await DBModel.User.findOne({ "security.token": token }); - if (!user) { - return ResponseUtil.unauthorized(ctx, "用户未登录或 token 无效"); - } - - if (!('wxapp-escort-admin' in user.app)) { - return ResponseUtil.unauthorized(ctx, "用户无管理员权限"); - } - - const isTokenValid = user.security.token && - user.security.tokenExpiry && - new Date() < user.security.tokenExpiry; - if (!isTokenValid) { - return ResponseUtil.unauthorized(ctx, "登录已过期,请重新登录"); - } - - // 查询所有user.app包含wxapp-escort的用户 const users = await DBModel.User.find({ "app.wxapp-escort": { $exists: true } }) .skip((page - 1) * pageSize) .limit(pageSize); - // 安全起见删除密码相关字段 - users.forEach(u => { - delete u.security.passwd; - delete u.security.passwdSalt; - }); - - return ResponseUtil.success(ctx, { users }, "获取用户列表成功"); + const safeUsers = users.map(u => sanitizeUser(u)); + return ResponseUtil.success(ctx, { users: safeUsers }, "获取用户列表成功"); } catch (err) { return ResponseUtil.internalError(ctx, err.message); } @@ -305,13 +278,13 @@ class HandlerUser { // 微信登录 async wxSignin(ctx) { + const { error, value } = wxSigninSchema.validate(ctx.request.body, { abortEarly: false }); + if (error) { + return ResponseUtil.badRequest(ctx, error.details[0].message); + } + try { - const { code, phoneNumber, name, appId } = ctx.request.body; - if (!code || !appId) { - return ResponseUtil.badRequest(ctx, "缺少微信登录凭证 code 或 appId"); - } - - + const { code, phoneNumber, name, appId } = value; let app = config.app[appId]; if (!app) { @@ -342,17 +315,15 @@ class HandlerUser { user = await DBModel.User.findOne({ "profile.mobile": phoneNumber }); if (!user) { const newUser = { - profile: { name: name || phoneNumber, mobile: phoneNumber, }, - status: { account: "normal", }, + profile: { name: name || phoneNumber, mobile: phoneNumber }, + status: { account: "normal" }, app: {}, }; newUser.app[appId] = { role: ["user"], wxopenid: openid }; - user = await DBModel.User.setUser(newUser); } } - // if (user) { if (phoneNumber && phoneNumber.length > 0 && user.profile.mobile !== phoneNumber) { user.profile.mobile = phoneNumber; @@ -366,22 +337,14 @@ class HandlerUser { } // 更新Token - const isTokenValid = user.security.token && - user.security.tokenExpiry && - new Date() < user.security.tokenExpiry; - - if (!isTokenValid) { - const token = await this.genToken(user._id.toString()); - user.security.token = token; - } + const token = await this.genToken(user._id.toString()); + user.security.token = token; user.security.tokenExpiry = new Date(Date.now() + 15 * 24 * 60 * 60 * 1000); + user.security.lastLoginAt = new Date(); + user.security.lastLoginIp = ctx.ip || ctx.request.ip; await user.save(); - // 安全起见删除密码相关字段 - delete user.security.passwd; - delete user.security.passwdSalt; - - return ResponseUtil.success(ctx, { user }, "登录成功"); + return ResponseUtil.success(ctx, { user: sanitizeUser(user) }, "登录成功"); } catch (err) { return ResponseUtil.internalError(ctx, err.message); } @@ -389,11 +352,13 @@ class HandlerUser { // 获取微信的手机号码 async wxGetPhoneNumber(ctx) { + const { error, value } = wxGetPhoneSchema.validate(ctx.request.body, { abortEarly: false }); + if (error) { + return ResponseUtil.badRequest(ctx, error.details[0].message); + } + try { - const { code, appId } = ctx.request.body; - if (!code || !appId) { - return ResponseUtil.badRequest(ctx, "缺少手机号凭证 code 或 appId"); - } + const { code, appId } = value; let app = config.app[appId]; if (!app) { @@ -421,7 +386,6 @@ class HandlerUser { return ResponseUtil.error(ctx, `获取手机号失败: ${phoneData.errmsg}`, null, 400); } - // 从上图获取手机号 const phoneNumber = phoneData.phone_info?.phoneNumber; return ResponseUtil.success(ctx, { phoneNumber }, "获取手机号成功"); } catch (err) { diff --git a/middleware/auth.js b/middleware/auth.js new file mode 100644 index 0000000..901cc5d --- /dev/null +++ b/middleware/auth.js @@ -0,0 +1,83 @@ +import { DBModel } from '../models/index.js'; +import ResponseUtil from '../utils/api_response.js'; + +/** + * 从请求中提取 token(优先 Authorization header) + */ +function extractToken(ctx) { + // 优先从 Authorization: Bearer xxx 获取 + const authHeader = ctx.header?.authorization; + if (authHeader && authHeader.startsWith('Bearer ')) { + return authHeader.slice(7); + } + + // 兼容旧方式:body/query/header 中的 token 字段 + return ctx.request.body?.token + || ctx.request.query?.token + || ctx.header?.token; +} + +/** + * 认证中间件 - 验证 token 并挂载用户信息到 ctx + * 可选参数: + * - required: 是否必须登录(默认 true) + * - roles: 允许的角色列表(可选) + */ +function auth(options = {}) { + const { required = true, roles } = options; + + return async (ctx, next) => { + const token = extractToken(ctx); + + if (!token) { + if (!required) { + ctx.state.user = null; + return await next(); + } + return ResponseUtil.unauthorized(ctx, '缺少认证 token'); + } + + const user = await DBModel.User.findOne({ 'security.token': token }); + if (!user) { + return ResponseUtil.unauthorized(ctx, '用户未登录或 token 无效'); + } + + if (user.security.tokenExpiry && new Date() > user.security.tokenExpiry) { + return ResponseUtil.unauthorized(ctx, '登录已过期,请重新登录'); + } + + if (user.status.account === 'lock') { + return ResponseUtil.forbidden(ctx, '账户已被锁定'); + } + + // 角色检查 + if (roles && roles.length > 0) { + const hasRole = roles.some(role => { + // 检查 app 字段中的角色 + return Object.values(user.app || {}).some(appData => + appData && Array.isArray(appData.role) && appData.role.includes(role) + ); + }); + if (!hasRole) { + return ResponseUtil.forbidden(ctx, '权限不足'); + } + } + + ctx.state.user = user; + await next(); + }; +} + +/** + * 返回用户安全对象(去除密码等敏感字段) + */ +function sanitizeUser(user) { + const obj = user.toObject ? user.toObject() : { ...user }; + delete obj.security?.passwd; + delete obj.security?.passwdSalt; + delete obj.security?.passwordResetToken; + delete obj.security?.passwordResetExpiry; + return obj; +} + +export { auth, extractToken, sanitizeUser }; diff --git a/middleware/ratelimit.js b/middleware/ratelimit.js new file mode 100644 index 0000000..2d19568 --- /dev/null +++ b/middleware/ratelimit.js @@ -0,0 +1,51 @@ +import ResponseUtil from '../utils/api_response.js'; + +/** + * 内存速率限制 + * 基于 IP + 接口路径维度,不引入 Redis + */ +const rateLimitStore = new Map(); + +// 每 60 秒清理一次过期记录 +const cleanupTimer = setInterval(() => { + const now = Date.now(); + for (const [key, record] of rateLimitStore.entries()) { + if (now - record.resetAt > record.windowMs) { + rateLimitStore.delete(key); + } + } +}, 60_000); +cleanupTimer.unref(); + +/** + * 创建速率限制中间件 + * @param {number} max - 窗口内最大请求数 + * @param {number} windowMs - 窗口时长(毫秒),默认 60 秒 + */ +function rateLimit(max = 10, windowMs = 60_000) { + return async (ctx, next) => { + const ip = ctx.ip || ctx.request.ip; + const key = `${ip}:${ctx.path}`; + const now = Date.now(); + + let record = rateLimitStore.get(key); + if (!record || now - record.resetAt > windowMs) { + record = { count: 0, resetAt: now, windowMs }; + rateLimitStore.set(key, record); + } + + record.count++; + + if (record.count > max) { + const retryAfter = Math.ceil((record.resetAt + windowMs - now) / 1000); + ctx.set('Retry-After', retryAfter); + return ResponseUtil.error(ctx, `请求过于频繁,请 ${retryAfter} 秒后重试`, null, 429); + } + + ctx.set('X-RateLimit-Limit', max); + ctx.set('X-RateLimit-Remaining', Math.max(0, max - record.count)); + await next(); + }; +} + +export { rateLimit }; diff --git a/models/schema/user.js b/models/schema/user.js index ae681a7..c35b3a5 100644 --- a/models/schema/user.js +++ b/models/schema/user.js @@ -30,7 +30,10 @@ const UserSchema = mongoose.Schema( token: { type: String, index: true, sparse: true, comment: '认证令牌' }, tokenExpiry: { type: Date, comment: '令牌过期时间' }, failedLoginAttempts: { type: Number, default: 0, comment: '失败登录尝试次数' }, - lockedUntil: { type: Date, comment: '账户锁定时间' }, + lastLoginAt: { type: Date, comment: '最后登录时间' }, + lastLoginIp: { type: String, comment: '最后登录IP' }, + passwordResetToken: { type: String, sparse: true, comment: '密码重置令牌' }, + passwordResetExpiry: { type: Date, comment: '密码重置令牌过期时间' }, }, // 位置信息 - 用户的省份、城市和区县 @@ -56,7 +59,6 @@ const UserSchema = mongoose.Schema( // 社交信息 - 用户的社交账号关联 social: { wechat:{ - account: { type: String, default: "", comment: '微信账号' }, unionid: { type: String, index: true, unique: true, sparse: true, comment: '微信UnionID' }, } }, @@ -270,7 +272,6 @@ UserSchema.statics.resetFailedLoginAttempts = async function (_id) { { _id }, { 'security.failedLoginAttempts': 0, - 'security.lockedUntil': null, 'meta.updatetime': Date.now() }, { new: true } diff --git a/package-lock.json b/package-lock.json index f5d80fd..66b095b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "1.0.0", "dependencies": { "@koa/cors": "^5.0.0", + "bcrypt": "^6.0.0", + "joi": "^18.2.1", "koa": "^2.15.3", "koa-bodyparser": "^4.4.1", "koa-router": "^12.0.1", @@ -19,12 +21,60 @@ "supertest": "^7.0.0" } }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@hapi/bourne": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/@hapi/bourne/-/bourne-3.0.0.tgz", "integrity": "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==", "license": "BSD-3-Clause" }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.7.tgz", + "integrity": "sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, "node_modules/@koa/cors": { "version": "5.0.0", "resolved": "https://registry.npmmirror.com/@koa/cors/-/cors-5.0.0.tgz", @@ -69,6 +119,12 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@types/webidl-conversions": { "version": "7.0.3", "resolved": "https://registry.npmmirror.com/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", @@ -111,6 +167,20 @@ "dev": true, "license": "MIT" }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/bson": { "version": "6.10.4", "resolved": "https://registry.npmmirror.com/bson/-/bson-6.10.4.tgz", @@ -730,6 +800,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/joi": { + "version": "18.2.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz", + "integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/kareem": { "version": "2.6.3", "resolved": "https://registry.npmmirror.com/kareem/-/kareem-2.6.3.tgz", @@ -1045,6 +1133,15 @@ "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz", + "integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz", @@ -1083,6 +1180,17 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", diff --git a/package.json b/package.json index 23ecdd5..2129665 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,11 @@ "test": "node --test test/*.test.js" }, "dependencies": { + "@koa/cors": "^5.0.0", + "bcrypt": "^6.0.0", + "joi": "^18.2.1", "koa": "^2.15.3", "koa-bodyparser": "^4.4.1", - "@koa/cors": "^5.0.0", "koa-router": "^12.0.1", "mongoose": "^8.4.0", "node-fetch": "^3.3.2" diff --git a/routes/index.js b/routes/index.js index 66c02fc..bf4c111 100644 --- a/routes/index.js +++ b/routes/index.js @@ -1,5 +1,6 @@ import Router from 'koa-router'; import { HandlerUser } from '../handler/users.js'; +import { rateLimit } from '../middleware/ratelimit.js'; class ApiRouter { constructor() { @@ -11,14 +12,15 @@ class ApiRouter { setupRoutes() { const userRouter = new Router({ prefix: '/user' }); - userRouter.post('/register', this.handler.register.bind(this.handler)); - userRouter.post('/signin', this.handler.signin.bind(this.handler)); + // 限流:注册/登录接口 5次/分钟,微信登录 10次/分钟 + userRouter.post('/register', rateLimit(5, 60_000), this.handler.register.bind(this.handler)); + userRouter.post('/signin', rateLimit(10, 60_000), this.handler.signin.bind(this.handler)); + userRouter.post('/wxsignin', rateLimit(10, 60_000), this.handler.wxSignin.bind(this.handler)); + userRouter.post('/signout', this.handler.signout.bind(this.handler)); userRouter.post('/userInfo', this.handler.userInfo.bind(this.handler)); userRouter.post('/update', this.handler.updateUser.bind(this.handler)); userRouter.post('/list', this.handler.userList.bind(this.handler)); - - userRouter.post('/wxsignin', this.handler.wxSignin.bind(this.handler)); userRouter.post('/wxgetphonenumber', this.handler.wxGetPhoneNumber.bind(this.handler)); this.router.use(userRouter.routes()); diff --git a/test/api.test.js b/test/api.test.js new file mode 100644 index 0000000..6341d35 --- /dev/null +++ b/test/api.test.js @@ -0,0 +1,265 @@ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import supertest from 'supertest'; +import { APP } from '../app.js'; +import { DBModel } from '../models/index.js'; + +describe('用户接口集成测试', () => { + let app, agent; + const testMobile = '13900000001'; + const testPasswd = 'test123456'; + let token = ''; + let userId = ''; + + before(async () => { + app = new APP(); + agent = supertest(app.app.callback()); + // 等待数据库连接 + await new Promise(resolve => setTimeout(resolve, 2000)); + }); + + after(async () => { + // 清理测试数据 + if (userId) { + await DBModel.User.delUser(userId); + } + app.stop(); + }); + + describe('POST /user/register', () => { + it('应成功注册新用户', async () => { + const res = await agent + .post('/user/register') + .send({ + userInfo: { + profile: { mobile: testMobile, name: '测试用户' }, + security: { passwd: testPasswd }, + }, + }); + + assert.equal(res.body.code, 0, `注册失败: ${res.body.msg}`); + assert.ok(res.body.data.user); + assert.ok(res.body.data.user.security.token); + assert.equal(res.body.data.user.profile.mobile, testMobile); + // 密码字段不应返回 + assert.equal(res.body.data.user.security.passwd, undefined); + assert.equal(res.body.data.user.security.passwdSalt, undefined); + + token = res.body.data.user.security.token; + userId = res.body.data.user._id; + }); + + it('重复注册应返回 409', async () => { + const res = await agent + .post('/user/register') + .send({ + userInfo: { + profile: { mobile: testMobile, name: '测试用户' }, + security: { passwd: testPasswd }, + }, + }); + + assert.equal(res.body.code, 409); + assert.equal(res.body.msg, '手机号已注册'); + }); + + it('缺少手机号应返回 400', async () => { + const res = await agent + .post('/user/register') + .send({ + userInfo: { + profile: { name: '测试用户' }, + security: { passwd: testPasswd }, + }, + }); + + assert.equal(res.body.code, 400); + }); + + it('缺少密码应返回 400', async () => { + const res = await agent + .post('/user/register') + .send({ + userInfo: { + profile: { mobile: '13900000099' }, + security: {}, + }, + }); + + assert.equal(res.body.code, 400); + }); + + it('密码少于6位应返回 400', async () => { + const res = await agent + .post('/user/register') + .send({ + userInfo: { + profile: { mobile: '13900000098' }, + security: { passwd: '123' }, + }, + }); + + assert.equal(res.body.code, 400); + }); + }); + + describe('POST /user/signin', () => { + it('用正确密码登录应成功', async () => { + const res = await agent + .post('/user/signin') + .send({ mobile: testMobile, passwd: testPasswd }); + + assert.equal(res.body.code, 0, `登录失败: ${res.body.msg}`); + assert.ok(res.body.data.user.security.token); + // 更新 token + token = res.body.data.user.security.token; + // 应记录最后登录信息 + assert.ok(res.body.data.user.security.lastLoginAt); + }); + + it('错误密码应返回 401', async () => { + const res = await agent + .post('/user/signin') + .send({ mobile: testMobile, passwd: 'wrongpassword' }); + + assert.equal(res.body.code, 401); + }); + + it('不存在的用户应返回 401', async () => { + const res = await agent + .post('/user/signin') + .send({ mobile: '13999999999', passwd: 'test123456' }); + + assert.equal(res.body.code, 401); + }); + + it('缺少手机号应返回 400', async () => { + const res = await agent + .post('/user/signin') + .send({ passwd: testPasswd }); + + assert.equal(res.body.code, 400); + }); + }); + + describe('POST /user/userInfo', () => { + it('用 Authorization Bearer 获取用户信息', async () => { + const res = await agent + .post('/user/userInfo') + .set('Authorization', `Bearer ${token}`) + .send({}); + + assert.equal(res.body.code, 0, `获取用户信息失败: ${res.body.msg}`); + assert.equal(res.body.data.user.profile.mobile, testMobile); + assert.equal(res.body.data.user.security.passwd, undefined); + }); + + it('用 body token 获取用户信息(向后兼容)', async () => { + const res = await agent + .post('/user/userInfo') + .send({ token }); + + assert.equal(res.body.code, 0); + assert.equal(res.body.data.user.profile.mobile, testMobile); + }); + + it('无效 token 应返回 401', async () => { + const res = await agent + .post('/user/userInfo') + .set('Authorization', 'Bearer invalid_token') + .send({}); + + assert.equal(res.body.code, 401); + }); + + it('缺少 token 应返回 400', async () => { + const res = await agent + .post('/user/userInfo') + .send({}); + + assert.equal(res.body.code, 400); + }); + }); + + describe('POST /user/update', () => { + it('更新用户信息应成功', async () => { + const res = await agent + .post('/user/update') + .set('Authorization', `Bearer ${token}`) + .send({ + profile: { name: '更新后的名字' }, + }); + + assert.equal(res.body.code, 0, `更新失败: ${res.body.msg}`); + assert.equal(res.body.data.user.profile.name, '更新后的名字'); + }); + + it('无 token 更新应返回 400', async () => { + const res = await agent + .post('/user/update') + .send({ profile: { name: 'test' } }); + + assert.equal(res.body.code, 400); + }); + }); + + describe('POST /user/signout', () => { + it('退出登录应成功', async () => { + const res = await agent + .post('/user/signout') + .set('Authorization', `Bearer ${token}`) + .send({}); + + assert.equal(res.body.code, 0); + assert.equal(res.body.msg, '退出登录成功'); + }); + + it('退出后用旧 token 获取信息应返回 401', async () => { + const res = await agent + .post('/user/userInfo') + .set('Authorization', `Bearer ${token}`) + .send({}); + + assert.equal(res.body.code, 401); + }); + }); + + describe('密码迁移:MD5 → bcrypt', () => { + it('旧 MD5 用户登录应自动升级密码', async () => { + // 先注册一个新用户 + const mobile = '13900000002'; + const passwd = 'md5test123'; + + // 直接写入一个 MD5 密码的用户 + const crypto = await import('crypto'); + const salt = crypto.randomBytes(8).toString('hex'); + const md5Hash = crypto.createHash('md5').update(passwd + salt).digest('hex'); + + const newUser = { + profile: { mobile, name: 'MD5测试用户' }, + security: { passwd: md5Hash, passwdSalt: salt }, + status: { account: 'normal' }, + app: {}, + social: { wechat: {} }, + }; + + const user = await DBModel.User.setUser(newUser); + assert.ok(user); + + // 用旧密码登录 + const res = await agent + .post('/user/signin') + .send({ mobile, passwd }); + + assert.equal(res.body.code, 0, `MD5迁移登录失败: ${res.body.msg}`); + + // 验证密码已升级为 bcrypt + const updatedUser = await DBModel.User.findOne({ 'profile.mobile': mobile }); + assert.ok(updatedUser.security.passwd.startsWith('$2'), '密码应已升级为 bcrypt'); + assert.equal(updatedUser.security.passwdSalt, undefined, 'salt 应已清除'); + + // 清理 + await DBModel.User.delUser(updatedUser._id); + }); + }); +}); diff --git a/test/auth.test.js b/test/auth.test.js new file mode 100644 index 0000000..c3f5562 --- /dev/null +++ b/test/auth.test.js @@ -0,0 +1,85 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { extractToken, sanitizeUser } from '../middleware/auth.js'; + +// 模拟 ctx 对象 +function mockCtx(overrides = {}) { + return { + header: {}, + request: { body: {}, query: {} }, + ip: '127.0.0.1', + ...overrides, + }; +} + +describe('extractToken', () => { + it('优先从 Authorization Bearer header 提取', () => { + const ctx = mockCtx({ + header: { authorization: 'Bearer abc123' }, + request: { body: { token: 'body_token' } }, + }); + assert.equal(extractToken(ctx), 'abc123'); + }); + + it('无 Bearer header 时从 body 提取', () => { + const ctx = mockCtx({ + request: { body: { token: 'body_token' } }, + }); + assert.equal(extractToken(ctx), 'body_token'); + }); + + it('无 Bearer header 时从 query 提取', () => { + const ctx = mockCtx({ + request: { query: { token: 'query_token' } }, + }); + assert.equal(extractToken(ctx), 'query_token'); + }); + + it('无 Bearer header 时从 header token 字段提取', () => { + const ctx = mockCtx({ + header: { token: 'header_token' }, + }); + assert.equal(extractToken(ctx), 'header_token'); + }); + + it('无任何 token 时返回 undefined', () => { + const ctx = mockCtx(); + assert.equal(extractToken(ctx), undefined); + }); +}); + +describe('sanitizeUser', () => { + it('应删除密码和重置令牌相关字段', () => { + const user = { + toObject: () => ({ + profile: { mobile: '13800138000' }, + security: { + passwd: 'hashed', + passwdSalt: 'salt', + token: 'valid_token', + passwordResetToken: 'reset_token', + passwordResetExpiry: new Date(), + }, + }), + }; + + const safe = sanitizeUser(user); + assert.equal(safe.security.passwd, undefined); + assert.equal(safe.security.passwdSalt, undefined); + assert.equal(safe.security.passwordResetToken, undefined); + assert.equal(safe.security.passwordResetExpiry, undefined); + assert.equal(safe.security.token, 'valid_token'); + assert.equal(safe.profile.mobile, '13800138000'); + }); + + it('处理普通对象(无 toObject 方法)', () => { + const user = { + profile: { mobile: '13800138000' }, + security: { passwd: 'x', passwdSalt: 'y' }, + }; + + const safe = sanitizeUser(user); + assert.equal(safe.security.passwd, undefined); + assert.equal(safe.security.passwdSalt, undefined); + }); +}); diff --git a/test/crypto.test.js b/test/crypto.test.js new file mode 100644 index 0000000..e2a9ff4 --- /dev/null +++ b/test/crypto.test.js @@ -0,0 +1,51 @@ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { hashPassword, verifyPassword } from '../utils/crypto.js'; + +describe('crypto 工具', () => { + it('bcrypt 加密后验证应通过', async () => { + const passwd = 'test123456'; + const hash = await hashPassword(passwd); + assert.ok(hash.startsWith('$2'), 'bcrypt hash 应以 $2 开头'); + + const { valid, needsUpgrade } = await verifyPassword(passwd, hash, ''); + assert.equal(valid, true); + assert.equal(needsUpgrade, false); + }); + + it('错误密码验证应失败', async () => { + const hash = await hashPassword('correct'); + const { valid } = await verifyPassword('wrong', hash, ''); + assert.equal(valid, false); + }); + + it('兼容旧 MD5 密码验证', async () => { + const passwd = 'mypass'; + const salt = 'abc123'; + const crypto = await import('crypto'); + const md5Hash = crypto.createHash('md5').update(passwd + salt).digest('hex'); + + const { valid, needsUpgrade } = await verifyPassword(passwd, md5Hash, salt); + assert.equal(valid, true); + assert.equal(needsUpgrade, true, 'MD5 密码应标记为需要升级'); + }); + + it('MD5 密码错误时应返回 false', async () => { + const salt = 'abc123'; + const crypto = await import('crypto'); + const md5Hash = crypto.createHash('md5').update('correct' + salt).digest('hex'); + + const { valid } = await verifyPassword('wrong', md5Hash, salt); + assert.equal(valid, false); + }); + + it('空 salt 时 MD5 也能验证', async () => { + const passwd = 'test'; + const crypto = await import('crypto'); + const md5Hash = crypto.createHash('md5').update(passwd + '').digest('hex'); + + const { valid, needsUpgrade } = await verifyPassword(passwd, md5Hash, ''); + assert.equal(valid, true); + assert.equal(needsUpgrade, true); + }); +}); diff --git a/test/ratelimit.test.js b/test/ratelimit.test.js new file mode 100644 index 0000000..974516c --- /dev/null +++ b/test/ratelimit.test.js @@ -0,0 +1,49 @@ +import { describe, it, before, after, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import Koa from 'koa'; +import bodyParser from 'koa-bodyparser'; +import supertest from 'supertest'; +import { rateLimit } from '../middleware/ratelimit.js'; + +describe('rateLimit 中间件', () => { + let app, agent; + + before(() => { + app = new Koa(); + app.use(bodyParser()); + app.use(rateLimit(3, 60_000)); + app.use(async (ctx) => { + ctx.body = { ok: true }; + }); + agent = supertest(app.callback()); + }); + + it('前3次请求应正常通过', async () => { + for (let i = 0; i < 3; i++) { + const res = await agent.post('/test').send({}); + assert.equal(res.status, 200); + assert.equal(res.body.ok, true); + } + }); + + it('第4次请求应被限流', async () => { + const res = await agent.post('/test').send({}); + assert.equal(res.status, 200); // HTTP 200,业务码 429 + assert.equal(res.body.code, 429); + assert.ok(res.body.msg.includes('请求过于频繁')); + assert.ok(res.headers['retry-after']); + }); + + it('限流响应头应包含 X-RateLimit 信息', async () => { + // 新建一个不受之前计数影响的中间件(不同限流阈值 + 不同路径) + const freshApp = new Koa(); + freshApp.use(bodyParser()); + freshApp.use(rateLimit(100, 60_000)); + freshApp.use(async (ctx) => { ctx.body = { ok: true }; }); + const freshAgent = supertest(freshApp.callback()); + + const res = await freshAgent.post('/headercheck').send({}); + assert.equal(res.headers['x-ratelimit-limit'], '100'); + assert.equal(res.headers['x-ratelimit-remaining'], '99'); + }); +}); diff --git a/utils/crypto.js b/utils/crypto.js new file mode 100644 index 0000000..ae1a7c2 --- /dev/null +++ b/utils/crypto.js @@ -0,0 +1,36 @@ +import bcrypt from 'bcrypt'; + +const SALT_ROUNDS = 10; + +/** + * 加密密码(bcrypt) + */ +async function hashPassword(passwd) { + return await bcrypt.hash(passwd, SALT_ROUNDS); +} + +/** + * 验证密码 + * 支持 bcrypt 新密码和 MD5 旧密码(渐进式迁移) + * @returns {{ valid: boolean, needsUpgrade: boolean }} + */ +async function verifyPassword(passwd, storedHash, salt) { + // 尝试 bcrypt 验证 + if (storedHash && storedHash.startsWith('$2')) { + const valid = await bcrypt.compare(passwd, storedHash); + return { valid, needsUpgrade: false }; + } + + // 兼容旧 MD5 密码 + const crypto = await import('crypto'); + const hash = crypto.createHash('md5'); + hash.update(passwd + (salt || '')); + const md5Hash = hash.digest('hex'); + + return { + valid: storedHash === md5Hash, + needsUpgrade: storedHash === md5Hash + }; +} + +export { hashPassword, verifyPassword };