ai review
This commit is contained in:
@@ -1,2 +1,3 @@
|
|||||||
/node_modules/
|
/node_modules/
|
||||||
/logs/
|
/logs/
|
||||||
|
conf.json
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class APP {
|
|||||||
setupRoutes() {
|
setupRoutes() {
|
||||||
this.app.use(async (ctx, next) => {
|
this.app.use(async (ctx, next) => {
|
||||||
if (ctx.path === '/') {
|
if (ctx.path === '/') {
|
||||||
ResponseUtil.success(ctx, { name: 'attendant-api', version: '1.0.0' });
|
ResponseUtil.success(ctx, { name: 'user-api', version: '1.0.0' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await next();
|
await next();
|
||||||
|
|||||||
+134
-93
@@ -1,10 +1,31 @@
|
|||||||
import Joi from 'joi';
|
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 { hashPassword, verifyPassword, hashToken } from "../utils/crypto.js";
|
||||||
import { sanitizeUser, extractToken } from "../middleware/auth.js";
|
import { sanitizeUser } from "../middleware/auth.js";
|
||||||
import config from "../conf.json" with { type: "json" };
|
import config from "../conf.json" with { type: "json" };
|
||||||
|
|
||||||
|
// 微信 access_token 进程内缓存(有效期 7200s,提前 5 分钟刷新)
|
||||||
|
const wxAccessTokenCache = new Map();
|
||||||
|
|
||||||
|
async function getWxAccessToken(appConfig) {
|
||||||
|
const cached = wxAccessTokenCache.get(appConfig.appid);
|
||||||
|
if (cached && Date.now() < cached.expiresAt) {
|
||||||
|
return cached.token;
|
||||||
|
}
|
||||||
|
const fetch = (await import("node-fetch")).default;
|
||||||
|
const tokenUrl = `https://api.weixin.qq.com/cgi-bin/token?appid=${appConfig.appid}&secret=${appConfig.secret}&grant_type=client_credential`;
|
||||||
|
const resp = await (await fetch(tokenUrl)).json();
|
||||||
|
if (!resp.access_token) {
|
||||||
|
throw new Error(resp.errmsg || "获取微信 access_token 失败");
|
||||||
|
}
|
||||||
|
wxAccessTokenCache.set(appConfig.appid, {
|
||||||
|
token: resp.access_token,
|
||||||
|
expiresAt: Date.now() + ((resp.expires_in || 7200) - 300) * 1000,
|
||||||
|
});
|
||||||
|
return resp.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
// 输入校验 schema
|
// 输入校验 schema
|
||||||
const registerSchema = Joi.object({
|
const registerSchema = Joi.object({
|
||||||
userInfo: Joi.object({
|
userInfo: Joi.object({
|
||||||
@@ -45,6 +66,9 @@ const signinSchema = Joi.object({
|
|||||||
const wxSigninSchema = Joi.object({
|
const wxSigninSchema = Joi.object({
|
||||||
code: Joi.string().required().messages({ 'any.required': '缺少微信登录凭证 code' }),
|
code: Joi.string().required().messages({ 'any.required': '缺少微信登录凭证 code' }),
|
||||||
appId: Joi.string().required().messages({ 'any.required': '缺少 appId' }),
|
appId: Joi.string().required().messages({ 'any.required': '缺少 appId' }),
|
||||||
|
// 推荐:传微信手机号凭证 code,由服务端换取手机号(可信)
|
||||||
|
phoneCode: Joi.string().allow('', null),
|
||||||
|
// 已废弃(不安全):客户端直传手机号,仅允许用于新建账号
|
||||||
phoneNumber: Joi.string().allow('', null),
|
phoneNumber: Joi.string().allow('', null),
|
||||||
name: Joi.string().allow('', null),
|
name: Joi.string().allow('', null),
|
||||||
});
|
});
|
||||||
@@ -54,8 +78,10 @@ const wxGetPhoneSchema = Joi.object({
|
|||||||
appId: Joi.string().required().messages({ 'any.required': '缺少 appId' }),
|
appId: Joi.string().required().messages({ 'any.required': '缺少 appId' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const LOCK_AFTER_FAILED_ATTEMPTS = 10;
|
||||||
|
|
||||||
class HandlerUser {
|
class HandlerUser {
|
||||||
// 生成 token
|
// 生成 token(DB 中只存哈希,原始 token 仅在登录响应中返回一次)
|
||||||
async genToken(uid) {
|
async genToken(uid) {
|
||||||
const crypto = await import("crypto");
|
const crypto = await import("crypto");
|
||||||
const hash = crypto.createHash("sha256");
|
const hash = crypto.createHash("sha256");
|
||||||
@@ -63,6 +89,25 @@ class HandlerUser {
|
|||||||
return hash.digest("hex");
|
return hash.digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 签发 token:哈希入库,返回原始值供响应
|
||||||
|
async issueToken(user) {
|
||||||
|
const raw = await this.genToken(user._id.toString());
|
||||||
|
user.security.token = await hashToken(raw);
|
||||||
|
user.security.tokenExpiry = new Date(Date.now() + 15 * 24 * 60 * 60 * 1000);
|
||||||
|
user.security.lastLoginAt = new Date();
|
||||||
|
await user.save();
|
||||||
|
return { raw, tokenExpiry: user.security.tokenExpiry };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录/注册响应:脱敏后单独附加本次签发的原始 token
|
||||||
|
buildLoginResponse(user, raw) {
|
||||||
|
const safe = sanitizeUser(user);
|
||||||
|
safe.security = safe.security || {};
|
||||||
|
safe.security.token = raw;
|
||||||
|
safe.security.tokenExpiry = user.security.tokenExpiry;
|
||||||
|
return safe;
|
||||||
|
}
|
||||||
|
|
||||||
// 用户注册
|
// 用户注册
|
||||||
async register(ctx) {
|
async register(ctx) {
|
||||||
const { error, value } = registerSchema.validate(ctx.request.body, { abortEarly: false });
|
const { error, value } = registerSchema.validate(ctx.request.body, { abortEarly: false });
|
||||||
@@ -109,13 +154,10 @@ class HandlerUser {
|
|||||||
return ResponseUtil.internalError(ctx, "注册失败");
|
return ResponseUtil.internalError(ctx, "注册失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成 token
|
// 生成 token(哈希入库,响应附原始 token)
|
||||||
const token = await this.genToken(user._id.toString());
|
const { raw } = await this.issueToken(user);
|
||||||
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) }, "注册成功");
|
return ResponseUtil.success(ctx, { user: this.buildLoginResponse(user, raw) }, "注册成功");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return ResponseUtil.internalError(ctx, err.message);
|
return ResponseUtil.internalError(ctx, err.message);
|
||||||
}
|
}
|
||||||
@@ -137,14 +179,24 @@ class HandlerUser {
|
|||||||
return ResponseUtil.unauthorized(ctx, "用户不存在");
|
return ResponseUtil.unauthorized(ctx, "用户不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 锁定账户拒绝登录
|
||||||
|
if (user.status.account === "lock") {
|
||||||
|
return ResponseUtil.forbidden(ctx, "账户已被锁定,请联系管理员");
|
||||||
|
}
|
||||||
|
|
||||||
// 校验密码(支持 bcrypt 和 MD5 渐进式迁移)
|
// 校验密码(支持 bcrypt 和 MD5 渐进式迁移)
|
||||||
const { valid, needsUpgrade } = await verifyPassword(
|
const { valid, needsUpgrade } = await verifyPassword(
|
||||||
passwd, user.security.passwd, user.security.passwdSalt
|
passwd, user.security.passwd, user.security.passwdSalt
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
// 记录失败登录次数
|
// 记录失败登录次数,达到阈值锁定账户
|
||||||
await DBModel.User.incrementFailedLoginAttempts(user._id);
|
const updated = await DBModel.User.incrementFailedLoginAttempts(user._id);
|
||||||
|
if (updated && updated.security.failedLoginAttempts >= LOCK_AFTER_FAILED_ATTEMPTS) {
|
||||||
|
updated.status.account = "lock";
|
||||||
|
await updated.save();
|
||||||
|
return ResponseUtil.forbidden(ctx, "失败次数过多,账户已锁定,请联系管理员");
|
||||||
|
}
|
||||||
return ResponseUtil.unauthorized(ctx, "密码错误");
|
return ResponseUtil.unauthorized(ctx, "密码错误");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,15 +211,11 @@ class HandlerUser {
|
|||||||
await DBModel.User.resetFailedLoginAttempts(user._id);
|
await DBModel.User.resetFailedLoginAttempts(user._id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成/更新 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;
|
user.security.lastLoginIp = ctx.ip || ctx.request.ip;
|
||||||
await user.save();
|
const { raw } = await this.issueToken(user);
|
||||||
|
|
||||||
return ResponseUtil.success(ctx, { user: sanitizeUser(user) }, "登录成功");
|
return ResponseUtil.success(ctx, { user: this.buildLoginResponse(user, raw) }, "登录成功");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return ResponseUtil.internalError(ctx, err.message);
|
return ResponseUtil.internalError(ctx, err.message);
|
||||||
}
|
}
|
||||||
@@ -175,12 +223,8 @@ class HandlerUser {
|
|||||||
|
|
||||||
// 退出登录
|
// 退出登录
|
||||||
async signout(ctx) {
|
async signout(ctx) {
|
||||||
const token = extractToken(ctx);
|
// auth({required:false}) 中间件已验证登录态
|
||||||
if (!token) {
|
const user = ctx.state.user;
|
||||||
return ResponseUtil.badRequest(ctx, "缺少 token");
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await DBModel.User.findOne({ "security.token": token });
|
|
||||||
if (user) {
|
if (user) {
|
||||||
user.security.token = null;
|
user.security.token = null;
|
||||||
user.security.tokenExpiry = null;
|
user.security.tokenExpiry = null;
|
||||||
@@ -192,7 +236,12 @@ class HandlerUser {
|
|||||||
|
|
||||||
// 获取用户信息
|
// 获取用户信息
|
||||||
async userInfo(ctx) {
|
async userInfo(ctx) {
|
||||||
// 临时支持:通过 body 里的 userId 获取用户信息
|
// 已通过 auth 中间件验证的登录用户
|
||||||
|
if (ctx.state.user) {
|
||||||
|
return ResponseUtil.success(ctx, { user: sanitizeUser(ctx.state.user) }, "获取用户信息成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容:通过 userId 查询(脱敏响应,不含任何安全凭证)
|
||||||
const { userId } = ctx.request.body || {};
|
const { userId } = ctx.request.body || {};
|
||||||
if (userId) {
|
if (userId) {
|
||||||
const user = await DBModel.User.findOne({ _id: userId });
|
const user = await DBModel.User.findOne({ _id: userId });
|
||||||
@@ -202,40 +251,17 @@ class HandlerUser {
|
|||||||
return ResponseUtil.success(ctx, { user: sanitizeUser(user) }, "获取用户信息成功");
|
return ResponseUtil.success(ctx, { user: sanitizeUser(user) }, "获取用户信息成功");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 原有逻辑:通过 token 获取用户信息
|
return ResponseUtil.badRequest(ctx, "缺少 token 或 userId");
|
||||||
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) {
|
async updateUser(ctx) {
|
||||||
const token = extractToken(ctx);
|
// auth() 中间件已验证登录态
|
||||||
if (!token) {
|
const user = ctx.state.user;
|
||||||
return ResponseUtil.badRequest(ctx, "缺少 token");
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await DBModel.User.findOne({ "security.token": token });
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return ResponseUtil.unauthorized(ctx, "用户未登录或 token 无效");
|
return ResponseUtil.unauthorized(ctx, "用户未登录或 token 无效");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.security.tokenExpiry && new Date() > user.security.tokenExpiry) {
|
|
||||||
return ResponseUtil.unauthorized(ctx, "登录已过期,请重新登录");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const userInfo = ctx.request.body;
|
const userInfo = ctx.request.body;
|
||||||
if (!userInfo) {
|
if (!userInfo) {
|
||||||
@@ -255,33 +281,28 @@ class HandlerUser {
|
|||||||
|
|
||||||
// 获取用户列表
|
// 获取用户列表
|
||||||
async userList(ctx) {
|
async userList(ctx) {
|
||||||
const token = extractToken(ctx);
|
// auth() 中间件已验证登录态
|
||||||
if (!token) {
|
const user = ctx.state.user;
|
||||||
return ResponseUtil.badRequest(ctx, "缺少 token");
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await DBModel.User.findOne({ "security.token": token });
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return ResponseUtil.unauthorized(ctx, "用户未登录或 token 无效");
|
return ResponseUtil.unauthorized(ctx, "用户未登录或 token 无效");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!('wxapp-escort-admin' in user.app)) {
|
if (!("wxapp-escort-admin" in (user.app || {}))) {
|
||||||
return ResponseUtil.unauthorized(ctx, "用户无管理员权限");
|
return ResponseUtil.unauthorized(ctx, "用户无管理员权限");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.security.tokenExpiry && new Date() > user.security.tokenExpiry) {
|
|
||||||
return ResponseUtil.unauthorized(ctx, "登录已过期,请重新登录");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { page = 1, pageSize = 100 } = ctx.request.body;
|
const page = Math.max(1, parseInt(ctx.request.body?.page) || 1);
|
||||||
|
const pageSize = Math.min(200, Math.max(1, parseInt(ctx.request.body?.pageSize) || 100));
|
||||||
|
const filter = { "app.wxapp-escort": { $exists: true } };
|
||||||
|
|
||||||
const users = await DBModel.User.find({ "app.wxapp-escort": { $exists: true } })
|
const [users, total] = await Promise.all([
|
||||||
.skip((page - 1) * pageSize)
|
DBModel.User.find(filter).skip((page - 1) * pageSize).limit(pageSize),
|
||||||
.limit(pageSize);
|
DBModel.User.countDocuments(filter),
|
||||||
|
]);
|
||||||
|
|
||||||
const safeUsers = users.map(u => sanitizeUser(u));
|
const safeUsers = users.map((u) => sanitizeUser(u));
|
||||||
return ResponseUtil.success(ctx, { users: safeUsers }, "获取用户列表成功");
|
return ResponseUtil.success(ctx, { users: safeUsers, total, page, pageSize }, "获取用户列表成功");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return ResponseUtil.internalError(ctx, err.message);
|
return ResponseUtil.internalError(ctx, err.message);
|
||||||
}
|
}
|
||||||
@@ -302,6 +323,29 @@ class HandlerUser {
|
|||||||
return ResponseUtil.badRequest(ctx, `未配置 appId: ${appId}`);
|
return ResponseUtil.badRequest(ctx, `未配置 appId: ${appId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 手机号获取:
|
||||||
|
// 1) 优先用 phoneCode 由服务端向微信换取(可信,可绑定已有账号)
|
||||||
|
// 2) body 直传 phoneNumber 不可信,仅允许用于新建账号
|
||||||
|
let verifiedPhoneNumber = null;
|
||||||
|
if (value.phoneCode) {
|
||||||
|
try {
|
||||||
|
const accessToken = await getWxAccessToken(app);
|
||||||
|
const phoneUrl = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`;
|
||||||
|
const phoneRes = await fetch(phoneUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code: value.phoneCode }),
|
||||||
|
});
|
||||||
|
const phoneData = await phoneRes.json();
|
||||||
|
if (phoneData.errcode) {
|
||||||
|
return ResponseUtil.error(ctx, `获取手机号失败: ${phoneData.errmsg}`, null, 400);
|
||||||
|
}
|
||||||
|
verifiedPhoneNumber = phoneData.phone_info?.phoneNumber || null;
|
||||||
|
} catch (err) {
|
||||||
|
return ResponseUtil.internalError(ctx, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 通过 code 换取 openid/session_key
|
// 通过 code 换取 openid/session_key
|
||||||
const sessionUrl = `https://api.weixin.qq.com/sns/jscode2session?appid=${app.appid}&secret=${app.secret}&js_code=${code}&grant_type=authorization_code`;
|
const sessionUrl = `https://api.weixin.qq.com/sns/jscode2session?appid=${app.appid}&secret=${app.secret}&js_code=${code}&grant_type=authorization_code`;
|
||||||
const wxSessionRes = await fetch(sessionUrl);
|
const wxSessionRes = await fetch(sessionUrl);
|
||||||
@@ -315,18 +359,26 @@ class HandlerUser {
|
|||||||
return ResponseUtil.error(ctx, "微信登录失败,未获取到 openid", null, 400);
|
return ResponseUtil.error(ctx, "微信登录失败,未获取到 openid", null, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用openid和phoneNumber查询用户
|
|
||||||
let key = `app.${appId}.wxopenid`;
|
let key = `app.${appId}.wxopenid`;
|
||||||
let user = await DBModel.User.findOne({ [key]: openid });
|
let user = await DBModel.User.findOne({ [key]: openid });
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
if (!phoneNumber) {
|
const mobile = verifiedPhoneNumber || phoneNumber;
|
||||||
|
if (!mobile) {
|
||||||
return ResponseUtil.badRequest(ctx, "缺少手机号");
|
return ResponseUtil.badRequest(ctx, "缺少手机号");
|
||||||
}
|
}
|
||||||
|
|
||||||
user = await DBModel.User.findOne({ "profile.mobile": phoneNumber });
|
const existingUser = await DBModel.User.findOne({ "profile.mobile": mobile });
|
||||||
if (!user) {
|
if (existingUser) {
|
||||||
|
// 已有账号:仅允许服务端验证过的手机号绑定微信,防止账号接管
|
||||||
|
if (!verifiedPhoneNumber) {
|
||||||
|
return ResponseUtil.error(ctx, "该手机号已注册,请先使用手机号登录后再绑定微信", null, 409);
|
||||||
|
}
|
||||||
|
user = existingUser;
|
||||||
|
} else {
|
||||||
|
// 新建用户
|
||||||
const newUser = {
|
const newUser = {
|
||||||
profile: { name: name || phoneNumber, mobile: phoneNumber },
|
profile: { name: name || mobile, mobile },
|
||||||
status: { account: "normal" },
|
status: { account: "normal" },
|
||||||
app: {},
|
app: {},
|
||||||
};
|
};
|
||||||
@@ -336,26 +388,21 @@ class HandlerUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
if (phoneNumber && phoneNumber.length > 0 && user.profile.mobile !== phoneNumber) {
|
// 绑定/更新 openid;不覆盖已有用户的手机号
|
||||||
user.profile.mobile = phoneNumber;
|
|
||||||
}
|
|
||||||
if (!(appId in user.app)) {
|
if (!(appId in user.app)) {
|
||||||
user.app[appId] = { role: ["user"], wxopenid: openid };
|
user.app[appId] = { role: ["user"], wxopenid: openid };
|
||||||
|
} else {
|
||||||
|
user.app[appId].wxopenid = openid;
|
||||||
}
|
}
|
||||||
user.app[appId].wxopenid = openid;
|
|
||||||
} else {
|
} else {
|
||||||
return ResponseUtil.internalError(ctx, "用户不存在");
|
return ResponseUtil.internalError(ctx, "用户不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新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;
|
user.security.lastLoginIp = ctx.ip || ctx.request.ip;
|
||||||
await user.save();
|
const { raw } = await this.issueToken(user);
|
||||||
|
|
||||||
return ResponseUtil.success(ctx, { user: sanitizeUser(user) }, "登录成功");
|
return ResponseUtil.success(ctx, { user: this.buildLoginResponse(user, raw) }, "登录成功");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return ResponseUtil.internalError(ctx, err.message);
|
return ResponseUtil.internalError(ctx, err.message);
|
||||||
}
|
}
|
||||||
@@ -376,17 +423,11 @@ class HandlerUser {
|
|||||||
return ResponseUtil.badRequest(ctx, `未配置 appId: ${appId}`);
|
return ResponseUtil.badRequest(ctx, `未配置 appId: ${appId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取access_token
|
// 获取access_token(带缓存的封装,避免耗尽微信每日配额)
|
||||||
const client_credential_url = `https://api.weixin.qq.com/cgi-bin/token?appid=${app.appid}&secret=${app.secret}&grant_type=client_credential`;
|
const accessToken = await getWxAccessToken(app);
|
||||||
const fetch = (await import("node-fetch")).default;
|
|
||||||
let sessionRes = await fetch(client_credential_url);
|
|
||||||
const resp = await sessionRes.json();
|
|
||||||
if (!resp.access_token) {
|
|
||||||
return ResponseUtil.internalError(ctx, "获取微信 access_token 失败");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取phoneNumber
|
// 获取phoneNumber
|
||||||
const phoneUrl = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${resp.access_token}`;
|
const phoneUrl = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`;
|
||||||
const phoneRes = await fetch(phoneUrl, {
|
const phoneRes = await fetch(phoneUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { APP } from './app.js'
|
import { APP } from './app.js'
|
||||||
|
import config from './conf.json' with { type: 'json' };
|
||||||
|
|
||||||
// HTTP server
|
// HTTP server
|
||||||
const koaApp = new APP();
|
const koaApp = new APP();
|
||||||
koaApp.start(9010);
|
koaApp.start(parseInt(process.env.PORT || config.port || "9010"));
|
||||||
|
|||||||
+25
-3
@@ -1,5 +1,6 @@
|
|||||||
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 { hashToken } from '../utils/crypto.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从请求中提取 token(优先 Authorization header)
|
* 从请求中提取 token(优先 Authorization header)
|
||||||
@@ -17,6 +18,24 @@ function extractToken(ctx) {
|
|||||||
|| ctx.header?.token;
|
|| ctx.header?.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 token 查找用户
|
||||||
|
* DB 中存储的是 token 的 sha256 哈希;兼容旧明文 token,命中后自动迁移为哈希
|
||||||
|
*/
|
||||||
|
async function findUserByToken(rawToken) {
|
||||||
|
const hashed = await hashToken(rawToken);
|
||||||
|
let user = await DBModel.User.findOne({ 'security.token': hashed });
|
||||||
|
if (!user) {
|
||||||
|
// 兼容旧明文 token
|
||||||
|
user = await DBModel.User.findOne({ 'security.token': rawToken });
|
||||||
|
if (user) {
|
||||||
|
user.security.token = hashed;
|
||||||
|
await user.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 认证中间件 - 验证 token 并挂载用户信息到 ctx
|
* 认证中间件 - 验证 token 并挂载用户信息到 ctx
|
||||||
* 可选参数:
|
* 可选参数:
|
||||||
@@ -37,7 +56,7 @@ function auth(options = {}) {
|
|||||||
return ResponseUtil.unauthorized(ctx, '缺少认证 token');
|
return ResponseUtil.unauthorized(ctx, '缺少认证 token');
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await DBModel.User.findOne({ 'security.token': token });
|
const user = await findUserByToken(token);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return ResponseUtil.unauthorized(ctx, '用户未登录或 token 无效');
|
return ResponseUtil.unauthorized(ctx, '用户未登录或 token 无效');
|
||||||
}
|
}
|
||||||
@@ -69,15 +88,18 @@ function auth(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 返回用户安全对象(去除密码等敏感字段)
|
* 返回用户安全对象(去除密码、token 等敏感字段)
|
||||||
|
* 注意:登录/注册响应中的 token 由 handler 单独附加
|
||||||
*/
|
*/
|
||||||
function sanitizeUser(user) {
|
function sanitizeUser(user) {
|
||||||
const obj = user.toObject ? user.toObject() : { ...user };
|
const obj = user.toObject ? user.toObject() : { ...user };
|
||||||
delete obj.security?.passwd;
|
delete obj.security?.passwd;
|
||||||
delete obj.security?.passwdSalt;
|
delete obj.security?.passwdSalt;
|
||||||
|
delete obj.security?.token;
|
||||||
|
delete obj.security?.tokenExpiry;
|
||||||
delete obj.security?.passwordResetToken;
|
delete obj.security?.passwordResetToken;
|
||||||
delete obj.security?.passwordResetExpiry;
|
delete obj.security?.passwordResetExpiry;
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { auth, extractToken, sanitizeUser };
|
export { auth, extractToken, sanitizeUser, findUserByToken };
|
||||||
|
|||||||
+6
-8
@@ -9,8 +9,6 @@ class MongoDBSchema {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.dbConnection = null;
|
this.dbConnection = null;
|
||||||
this.User = null;
|
this.User = null;
|
||||||
this.EscortRecord = null;
|
|
||||||
this.Hospital = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
@@ -18,22 +16,22 @@ class MongoDBSchema {
|
|||||||
|
|
||||||
this.dbConnection = mongoose.createConnection(config.mongodb.str, config.mongodb.option);
|
this.dbConnection = mongoose.createConnection(config.mongodb.str, config.mongodb.option);
|
||||||
this.dbConnection.on("error", () => {
|
this.dbConnection.on("error", () => {
|
||||||
logger.error.bind(logger, "...mongodb connect error ...")
|
logger.error("...mongodb connect error ...");
|
||||||
});
|
});
|
||||||
this.dbConnection.on("connected", async () => {
|
this.dbConnection.on("connected", async () => {
|
||||||
logger.info("Mongodb: " + config.mongodb.str + " connected");
|
logger.info(`Mongodb: ${config.mongodb.host}/${config.mongodb.option.dbName} connected`);
|
||||||
});
|
});
|
||||||
this.dbConnection.on("disconnected", () =>
|
this.dbConnection.on("disconnected", () =>
|
||||||
logger.warn("Mongodb: " + config.mongodb.str + " disconnected")
|
logger.warn(`Mongodb: ${config.mongodb.host}/${config.mongodb.option.dbName} disconnected`)
|
||||||
);
|
);
|
||||||
this.dbConnection.on("reconnected", () =>
|
this.dbConnection.on("reconnected", () =>
|
||||||
logger.info("Mongodb: " + config.mongodb.str + " reconnected")
|
logger.info(`Mongodb: ${config.mongodb.host}/${config.mongodb.option.dbName} reconnected`)
|
||||||
);
|
);
|
||||||
this.dbConnection.on("disconnecting", () =>
|
this.dbConnection.on("disconnecting", () =>
|
||||||
logger.warn("Mongodb: " + config.mongodb.str + " disconnecting")
|
logger.warn(`Mongodb: ${config.mongodb.host}/${config.mongodb.option.dbName} disconnecting`)
|
||||||
);
|
);
|
||||||
this.dbConnection.on("close", () =>
|
this.dbConnection.on("close", () =>
|
||||||
logger.warn("Mongodb: " + config.mongodb.str + " closed")
|
logger.warn(`Mongodb: ${config.mongodb.host}/${config.mongodb.option.dbName} closed`)
|
||||||
);
|
);
|
||||||
|
|
||||||
this.User = this.dbConnection.model('user', UserSchema)
|
this.User = this.dbConnection.model('user', UserSchema)
|
||||||
|
|||||||
@@ -88,30 +88,6 @@ const UserSchema = mongoose.Schema(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据用户标识信息查找用户
|
|
||||||
*
|
|
||||||
* 支持通过手机号、邮箱、token或微信unionid查找用户
|
|
||||||
*
|
|
||||||
* @param {Object} _user - 包含查找条件的用户对象
|
|
||||||
* @param {Function} cb - 可选的回调函数
|
|
||||||
* @returns {Promise<Object|null>} 找到的用户对象或null
|
|
||||||
*/
|
|
||||||
UserSchema.statics.findUser = async function (_user, cb) {
|
|
||||||
let filter = {};
|
|
||||||
if (_user.profile && _user.profile.mobile) {
|
|
||||||
filter = { "profile.mobile": _user.profile.mobile };
|
|
||||||
} else if (_user.profile && _user.profile.email) {
|
|
||||||
filter = { "profile.email": new RegExp(_user.profile.email, "i") };
|
|
||||||
} else if (_user.security && _user.security.token) {
|
|
||||||
filter = { "security.token": new RegExp(_user.security.token, "i") };
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.findOne(filter, cb);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置/更新用户信息
|
* 设置/更新用户信息
|
||||||
*
|
*
|
||||||
|
|||||||
+6
-5
@@ -1,6 +1,7 @@
|
|||||||
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';
|
import { rateLimit } from '../middleware/ratelimit.js';
|
||||||
|
import { auth } from '../middleware/auth.js';
|
||||||
|
|
||||||
class ApiRouter {
|
class ApiRouter {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -17,11 +18,11 @@ class ApiRouter {
|
|||||||
userRouter.post('/signin', rateLimit(10, 60_000), this.handler.signin.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('/wxsignin', rateLimit(10, 60_000), this.handler.wxSignin.bind(this.handler));
|
||||||
|
|
||||||
userRouter.post('/signout', this.handler.signout.bind(this.handler));
|
userRouter.post('/signout', auth({ required: false }), this.handler.signout.bind(this.handler));
|
||||||
userRouter.post('/userInfo', this.handler.userInfo.bind(this.handler));
|
userRouter.post('/userInfo', auth({ required: false }), this.handler.userInfo.bind(this.handler));
|
||||||
userRouter.post('/update', this.handler.updateUser.bind(this.handler));
|
userRouter.post('/update', auth(), this.handler.updateUser.bind(this.handler));
|
||||||
userRouter.post('/list', this.handler.userList.bind(this.handler));
|
userRouter.post('/list', auth(), this.handler.userList.bind(this.handler));
|
||||||
userRouter.post('/wxgetphonenumber', this.handler.wxGetPhoneNumber.bind(this.handler));
|
userRouter.post('/wxgetphonenumber', rateLimit(10, 60_000), this.handler.wxGetPhoneNumber.bind(this.handler));
|
||||||
|
|
||||||
this.router.use(userRouter.routes());
|
this.router.use(userRouter.routes());
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -9,6 +9,14 @@ async function hashPassword(passwd) {
|
|||||||
return await bcrypt.hash(passwd, SALT_ROUNDS);
|
return await bcrypt.hash(passwd, SALT_ROUNDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对 token 做哈希(用于 DB 存储,避免明文落库)
|
||||||
|
*/
|
||||||
|
async function hashToken(token) {
|
||||||
|
const crypto = await import('crypto');
|
||||||
|
return crypto.createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证密码
|
* 验证密码
|
||||||
* 支持 bcrypt 新密码和 MD5 旧密码(渐进式迁移)
|
* 支持 bcrypt 新密码和 MD5 旧密码(渐进式迁移)
|
||||||
@@ -33,4 +41,4 @@ async function verifyPassword(passwd, storedHash, salt) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { hashPassword, verifyPassword };
|
export { hashPassword, verifyPassword, hashToken };
|
||||||
|
|||||||
Reference in New Issue
Block a user