Compare commits

..

3 Commits

Author SHA1 Message Date
lik
70307d4eb8 userInfo bug 2026-06-12 16:04:03 +08:00
lik
ddcf200de2 完善了安全功能 2026-06-12 15:24:20 +08:00
lik
fba44ca015 tmp 2026-06-12 08:59:48 +08:00
12 changed files with 1055 additions and 206 deletions

View File

@@ -1,48 +1,287 @@
import Joi from 'joi';
import { DBModel } from "../models/index.js"; import { DBModel } from "../models/index.js";
import ResponseUtil from "../utils/api_response.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" }; import config from "../conf.json" with { type: "json" };
class HandlerUser { // 输入校验 schema
constructor() { const registerSchema = Joi.object({
} userInfo: Joi.object({
profile: Joi.object({
// 获取微信的手机号码 mobile: Joi.string().required().messages({
async wxGetPhoneNumber(ctx) { 'any.required': '缺少手机号',
try { 'string.empty': '手机号不能为空'
const { code, appId } = ctx.request.body; }),
if (!code || !appId) { name: Joi.string().allow(''),
return ResponseUtil.badRequest(ctx, "缺少手机号凭证 code 或 appId"); }).required(),
} security: Joi.object({
passwd: Joi.string().min(6).required().messages({
let app = config.app[appId]; 'any.required': '缺少密码',
if (!app) { 'string.min': '密码至少6位'
return ResponseUtil.badRequest(ctx, `未配置 appId: ${appId}`); }),
} }).required(),
location: Joi.object({
// 获取access_token province: Joi.string().allow(''),
const client_credential_url = `https://api.weixin.qq.com/cgi-bin/token?appid=${app.appid}&secret=${app.secret}&grant_type=client_credential`; city: Joi.string().allow(''),
const fetch = (await import("node-fetch")).default; district: Joi.string().allow(''),
let sessionRes = await fetch(client_credential_url); }).optional(),
const resp = await sessionRes.json(); addresses: Joi.array().optional(),
if (!resp.access_token) { app: Joi.object().optional(),
return ResponseUtil.internalError(ctx, "获取微信 access_token 失败"); }).required(),
}
// 获取phoneNumber
const phoneUrl = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${resp.access_token}`;
const phoneRes = await fetch(phoneUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: code })
}); });
const phoneData = await phoneRes.json();
if (phoneData.errcode) { const signinSchema = Joi.object({
return ResponseUtil.error(ctx, `获取手机号失败: ${phoneData.errmsg}`, null, 400); 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("sha256");
hash.update(uid + Date.now() + Math.random());
return hash.digest("hex");
} }
// 从上图获取手机号 // 用户注册
const phoneNumber = phoneData.phone_info?.phoneNumber; async register(ctx) {
return ResponseUtil.success(ctx, { phoneNumber }, "获取手机号成功"); const { error, value } = registerSchema.validate(ctx.request.body, { abortEarly: false });
if (error) {
return ResponseUtil.badRequest(ctx, error.details[0].message);
}
try {
const { userInfo } = value;
const mobile = userInfo.profile.mobile;
const passwd = userInfo.security.passwd;
// 检查手机号是否已注册
const existingUser = await DBModel.User.findOne({ "profile.mobile": mobile });
if (existingUser) {
return ResponseUtil.error(ctx, "手机号已注册", null, 409);
}
// bcrypt 加密密码
const encryptedPasswd = await hashPassword(passwd);
const newUser = {
profile: userInfo.profile,
security: {
passwd: encryptedPasswd,
},
location: {
province: userInfo.location?.province || '',
city: userInfo.location?.city || '',
district: userInfo.location?.district || '',
},
addresses: userInfo.addresses || [],
social: {
wechat: {}
},
status: {
account: "normal",
},
app: userInfo.app || {},
};
const user = await DBModel.User.setUser(newUser);
if (!user) {
return ResponseUtil.internalError(ctx, "注册失败");
}
// 生成 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);
await user.save();
return ResponseUtil.success(ctx, { user: sanitizeUser(user) }, "注册成功");
} catch (err) {
return ResponseUtil.internalError(ctx, err.message);
}
}
// 手机号码登录
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 } = value;
// 查找用户
let user = await DBModel.User.findOne({ "profile.mobile": mobile });
if (!user) {
return ResponseUtil.unauthorized(ctx, "用户不存在");
}
// 校验密码(支持 bcrypt 和 MD5 渐进式迁移)
const { valid, needsUpgrade } = await verifyPassword(
passwd, user.security.passwd, user.security.passwdSalt
);
if (!valid) {
// 记录失败登录次数
await DBModel.User.incrementFailedLoginAttempts(user._id);
return ResponseUtil.unauthorized(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 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();
return ResponseUtil.success(ctx, { user: sanitizeUser(user) }, "登录成功");
} catch (err) {
return ResponseUtil.internalError(ctx, err.message);
}
}
// 退出登录
async signout(ctx) {
const token = extractToken(ctx);
if (!token) {
return ResponseUtil.badRequest(ctx, "缺少 token");
}
const user = await DBModel.User.findOne({ "security.token": token });
if (user) {
user.security.token = null;
user.security.tokenExpiry = null;
await user.save();
}
return ResponseUtil.success(ctx, null, "退出登录成功");
}
// 获取用户信息
async userInfo(ctx) {
// 临时支持:通过 body 里的 userId 获取用户信息
const { userId } = ctx.request.body || {};
if (userId) {
const user = await DBModel.User.findOne({ _id: userId });
if (!user) {
return ResponseUtil.notFound(ctx, "用户不存在");
}
return ResponseUtil.success(ctx, { user: sanitizeUser(user) }, "获取用户信息成功");
}
// 原有逻辑:通过 token 获取用户信息
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) }, "获取用户信息成功");
}
// 更新用户信息
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, "缺少用户信息");
}
const updatedUser = await DBModel.User.updateFromUserInfo(user._id, userInfo);
if (!updatedUser) {
return ResponseUtil.internalError(ctx, "更新用户失败");
}
return ResponseUtil.success(ctx, { user: sanitizeUser(updatedUser) }, "更新成功");
} catch (err) {
return ResponseUtil.internalError(ctx, err.message);
}
}
// 获取用户列表
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;
const users = await DBModel.User.find({ "app.wxapp-escort": { $exists: true } })
.skip((page - 1) * pageSize)
.limit(pageSize);
const safeUsers = users.map(u => sanitizeUser(u));
return ResponseUtil.success(ctx, { users: safeUsers }, "获取用户列表成功");
} catch (err) { } catch (err) {
return ResponseUtil.internalError(ctx, err.message); return ResponseUtil.internalError(ctx, err.message);
} }
@@ -50,13 +289,13 @@ class HandlerUser {
// 微信登录 // 微信登录
async wxSignin(ctx) { async wxSignin(ctx) {
try { const { error, value } = wxSigninSchema.validate(ctx.request.body, { abortEarly: false });
const { code, phoneNumber, name, appId } = ctx.request.body; if (error) {
if (!code || !appId) { return ResponseUtil.badRequest(ctx, error.details[0].message);
return ResponseUtil.badRequest(ctx, "缺少微信登录凭证 code 或 appId");
} }
try {
const { code, phoneNumber, name, appId } = value;
let app = config.app[appId]; let app = config.app[appId];
if (!app) { if (!app) {
@@ -87,17 +326,15 @@ class HandlerUser {
user = await DBModel.User.findOne({ "profile.mobile": phoneNumber }); user = await DBModel.User.findOne({ "profile.mobile": phoneNumber });
if (!user) { if (!user) {
const newUser = { const newUser = {
profile: { name: name || phoneNumber, mobile: phoneNumber, }, profile: { name: name || phoneNumber, mobile: phoneNumber },
status: { account: "normal", }, status: { account: "normal" },
app: {}, app: {},
}; };
newUser.app[appId] = { role: ["user"], wxopenid: openid }; newUser.app[appId] = { role: ["user"], wxopenid: openid };
user = await DBModel.User.setUser(newUser); user = await DBModel.User.setUser(newUser);
} }
} }
//
if (user) { if (user) {
if (phoneNumber && phoneNumber.length > 0 && user.profile.mobile !== phoneNumber) { if (phoneNumber && phoneNumber.length > 0 && user.profile.mobile !== phoneNumber) {
user.profile.mobile = phoneNumber; user.profile.mobile = phoneNumber;
@@ -111,184 +348,61 @@ class HandlerUser {
} }
// 更新Token // 更新Token
const isTokenValid = user.security.token &&
user.security.tokenExpiry &&
new Date() < user.security.tokenExpiry;
if (!isTokenValid) {
const token = await this.genToken(user._id.toString()); const token = await this.genToken(user._id.toString());
user.security.token = token; user.security.token = token;
}
user.security.tokenExpiry = new Date(Date.now() + 15 * 24 * 60 * 60 * 1000); 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(); await user.save();
// 安全起见删除密码相关字段 return ResponseUtil.success(ctx, { user: sanitizeUser(user) }, "登录成功");
delete user.security.passwd;
delete user.security.passwdSalt;
return ResponseUtil.success(ctx, { user }, "登录成功");
} catch (err) { } catch (err) {
return ResponseUtil.internalError(ctx, err.message); return ResponseUtil.internalError(ctx, err.message);
} }
} }
// update user // 获取微信的手机号码
async updateUser(ctx) { async wxGetPhoneNumber(ctx) {
const { error, value } = wxGetPhoneSchema.validate(ctx.request.body, { abortEarly: false });
if (error) {
return ResponseUtil.badRequest(ctx, error.details[0].message);
}
try { try {
const userInfo = ctx.request.body; const { code, appId } = value;
if (!userInfo) {
return ResponseUtil.badRequest(ctx, "缺少用户信息"); let app = config.app[appId];
if (!app) {
return ResponseUtil.badRequest(ctx, `未配置 appId: ${appId}`);
} }
// 从 token 获取当前用户 // 获取access_token
const token = ctx.request.body?.token const client_credential_url = `https://api.weixin.qq.com/cgi-bin/token?appid=${app.appid}&secret=${app.secret}&grant_type=client_credential`;
|| ctx.request.query?.token const fetch = (await import("node-fetch")).default;
|| ctx.header?.authorization let sessionRes = await fetch(client_credential_url);
|| ctx.header?.token; const resp = await sessionRes.json();
if (!resp.access_token) {
if (!token) { return ResponseUtil.internalError(ctx, "获取微信 access_token 失败");
return ResponseUtil.badRequest(ctx, "缺少 token");
} }
const user = await DBModel.User.findOne({ "security.token": token }); // 获取phoneNumber
if (!user) { const phoneUrl = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${resp.access_token}`;
return ResponseUtil.unauthorized(ctx, "用户未登录或 token 无效"); const phoneRes = await fetch(phoneUrl, {
} method: 'POST',
headers: { 'Content-Type': 'application/json' },
// 检查 token 是否过期 body: JSON.stringify({ code: code })
if (user.security.tokenExpiry && new Date() > user.security.tokenExpiry) {
return ResponseUtil.unauthorized(ctx, "登录已过期,请重新登录");
}
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 }, "更新成功");
} catch (err) {
return ResponseUtil.internalError(ctx, err.message);
}
}
// 退出登录
async signout(ctx) {
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) {
user.security.token = null;
user.security.tokenExpiry = null;
await user.save();
}
return ResponseUtil.success(ctx, null, "退出登录成功");
}
// 获取用户信息
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);
}
}
// 获取用户列表
async userList(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;
}); });
const phoneData = await phoneRes.json();
if (phoneData.errcode) {
return ResponseUtil.error(ctx, `获取手机号失败: ${phoneData.errmsg}`, null, 400);
}
return ResponseUtil.success(ctx, { users }, "获取用户列表成功"); const phoneNumber = phoneData.phone_info?.phoneNumber;
return ResponseUtil.success(ctx, { phoneNumber }, "获取手机号成功");
} catch (err) { } catch (err) {
return ResponseUtil.internalError(ctx, err.message); return ResponseUtil.internalError(ctx, err.message);
} }
} }
// 生成 token
async genToken(uid) {
const crypto = await import("crypto");
const hash = crypto.createHash("md5");
hash.update(uid + Date.now() + Math.random());
return hash.digest("hex");
}
} }
export { HandlerUser }; export { HandlerUser };

83
middleware/auth.js Normal file
View File

@@ -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 };

51
middleware/ratelimit.js Normal file
View File

@@ -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 };

View File

@@ -30,7 +30,10 @@ const UserSchema = mongoose.Schema(
token: { type: String, index: true, sparse: true, comment: '认证令牌' }, token: { type: String, index: true, sparse: true, comment: '认证令牌' },
tokenExpiry: { type: Date, comment: '令牌过期时间' }, tokenExpiry: { type: Date, comment: '令牌过期时间' },
failedLoginAttempts: { type: Number, default: 0, 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: { social: {
wechat:{ wechat:{
account: { type: String, default: "", comment: '微信账号' },
unionid: { type: String, index: true, unique: true, sparse: true, comment: '微信UnionID' }, unionid: { type: String, index: true, unique: true, sparse: true, comment: '微信UnionID' },
} }
}, },
@@ -270,7 +272,6 @@ UserSchema.statics.resetFailedLoginAttempts = async function (_id) {
{ _id }, { _id },
{ {
'security.failedLoginAttempts': 0, 'security.failedLoginAttempts': 0,
'security.lockedUntil': null,
'meta.updatetime': Date.now() 'meta.updatetime': Date.now()
}, },
{ new: true } { new: true }

108
package-lock.json generated
View File

@@ -9,6 +9,8 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@koa/cors": "^5.0.0", "@koa/cors": "^5.0.0",
"bcrypt": "^6.0.0",
"joi": "^18.2.1",
"koa": "^2.15.3", "koa": "^2.15.3",
"koa-bodyparser": "^4.4.1", "koa-bodyparser": "^4.4.1",
"koa-router": "^12.0.1", "koa-router": "^12.0.1",
@@ -19,12 +21,60 @@
"supertest": "^7.0.0" "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": { "node_modules/@hapi/bourne": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmmirror.com/@hapi/bourne/-/bourne-3.0.0.tgz", "resolved": "https://registry.npmmirror.com/@hapi/bourne/-/bourne-3.0.0.tgz",
"integrity": "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==", "integrity": "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==",
"license": "BSD-3-Clause" "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": { "node_modules/@koa/cors": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmmirror.com/@koa/cors/-/cors-5.0.0.tgz", "resolved": "https://registry.npmmirror.com/@koa/cors/-/cors-5.0.0.tgz",
@@ -69,6 +119,12 @@
"@noble/hashes": "^1.1.5" "@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": { "node_modules/@types/webidl-conversions": {
"version": "7.0.3", "version": "7.0.3",
"resolved": "https://registry.npmmirror.com/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", "resolved": "https://registry.npmmirror.com/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
@@ -111,6 +167,20 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/bson": {
"version": "6.10.4", "version": "6.10.4",
"resolved": "https://registry.npmmirror.com/bson/-/bson-6.10.4.tgz", "resolved": "https://registry.npmmirror.com/bson/-/bson-6.10.4.tgz",
@@ -730,6 +800,24 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/kareem": {
"version": "2.6.3", "version": "2.6.3",
"resolved": "https://registry.npmmirror.com/kareem/-/kareem-2.6.3.tgz", "resolved": "https://registry.npmmirror.com/kareem/-/kareem-2.6.3.tgz",
@@ -1045,6 +1133,15 @@
"node": ">= 0.6" "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": { "node_modules/node-domexception": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz", "resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -1083,6 +1180,17 @@
"url": "https://opencollective.com/node-fetch" "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": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz",

View File

@@ -9,9 +9,11 @@
"test": "node --test test/*.test.js" "test": "node --test test/*.test.js"
}, },
"dependencies": { "dependencies": {
"@koa/cors": "^5.0.0",
"bcrypt": "^6.0.0",
"joi": "^18.2.1",
"koa": "^2.15.3", "koa": "^2.15.3",
"koa-bodyparser": "^4.4.1", "koa-bodyparser": "^4.4.1",
"@koa/cors": "^5.0.0",
"koa-router": "^12.0.1", "koa-router": "^12.0.1",
"mongoose": "^8.4.0", "mongoose": "^8.4.0",
"node-fetch": "^3.3.2" "node-fetch": "^3.3.2"

View File

@@ -1,5 +1,6 @@
import Router from 'koa-router'; import Router from 'koa-router';
import { HandlerUser } from '../handler/users.js'; import { HandlerUser } from '../handler/users.js';
import { rateLimit } from '../middleware/ratelimit.js';
class ApiRouter { class ApiRouter {
constructor() { constructor() {
@@ -11,13 +12,16 @@ class ApiRouter {
setupRoutes() { setupRoutes() {
const userRouter = new Router({ prefix: '/user' }); const userRouter = new Router({ prefix: '/user' });
userRouter.post('/wxgetphonenumber', this.handler.wxGetPhoneNumber.bind(this.handler)); // 限流:注册/登录接口 5次/分钟,微信登录 10次/分钟
userRouter.post('/wxsignin', this.handler.wxSignin.bind(this.handler)); userRouter.post('/register', rateLimit(5, 60_000), this.handler.register.bind(this.handler));
userRouter.post('/update', this.handler.updateUser.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('/signout', this.handler.signout.bind(this.handler));
userRouter.post('/userInfo', this.handler.userInfo.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('/list', this.handler.userList.bind(this.handler));
userRouter.post('/wxgetphonenumber', this.handler.wxGetPhoneNumber.bind(this.handler));
this.router.use(userRouter.routes()); this.router.use(userRouter.routes());

265
test/api.test.js Normal file
View File

@@ -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);
});
});
});

85
test/auth.test.js Normal file
View File

@@ -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);
});
});

51
test/crypto.test.js Normal file
View File

@@ -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);
});
});

49
test/ratelimit.test.js Normal file
View File

@@ -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');
});
});

36
utils/crypto.js Normal file
View File

@@ -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 };