Files
api_user/handler/users.js
T
2026-09-01 21:49:41 +08:00

450 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Joi from 'joi';
import { DBModel } from "../models/index.js";
import ResponseUtil from "../utils/api_response.js";
import { hashPassword, verifyPassword, hashToken } from "../utils/crypto.js";
import { sanitizeUser } from "../middleware/auth.js";
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
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' }),
// 推荐:传微信手机号凭证 code,由服务端换取手机号(可信)
phoneCode: Joi.string().allow('', null),
// 已废弃(不安全):客户端直传手机号,仅允许用于新建账号
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' }),
});
const LOCK_AFTER_FAILED_ATTEMPTS = 10;
class HandlerUser {
// 生成 tokenDB 中只存哈希,原始 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");
}
// 签发 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) {
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(哈希入库,响应附原始 token)
const { raw } = await this.issueToken(user);
return ResponseUtil.success(ctx, { user: this.buildLoginResponse(user, raw) }, "注册成功");
} 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, "用户不存在");
}
// 锁定账户拒绝登录
if (user.status.account === "lock") {
return ResponseUtil.forbidden(ctx, "账户已被锁定,请联系管理员");
}
// 校验密码(支持 bcrypt 和 MD5 渐进式迁移)
const { valid, needsUpgrade } = await verifyPassword(
passwd, user.security.passwd, user.security.passwdSalt
);
if (!valid) {
// 记录失败登录次数,达到阈值锁定账户
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, "密码错误");
}
// 渐进式迁移:如果是旧 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(哈希入库)
user.security.lastLoginIp = ctx.ip || ctx.request.ip;
const { raw } = await this.issueToken(user);
return ResponseUtil.success(ctx, { user: this.buildLoginResponse(user, raw) }, "登录成功");
} catch (err) {
return ResponseUtil.internalError(ctx, err.message);
}
}
// 退出登录
async signout(ctx) {
// auth({required:false}) 中间件已验证登录态
const user = ctx.state.user;
if (user) {
user.security.token = null;
user.security.tokenExpiry = null;
await user.save();
}
return ResponseUtil.success(ctx, null, "退出登录成功");
}
// 获取用户信息
async userInfo(ctx) {
// 已通过 auth 中间件验证的登录用户
if (ctx.state.user) {
return ResponseUtil.success(ctx, { user: sanitizeUser(ctx.state.user) }, "获取用户信息成功");
}
// 兼容:通过 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) }, "获取用户信息成功");
}
return ResponseUtil.badRequest(ctx, "缺少 token 或 userId");
}
// 更新用户信息
async updateUser(ctx) {
// auth() 中间件已验证登录态
const user = ctx.state.user;
if (!user) {
return ResponseUtil.unauthorized(ctx, "用户未登录或 token 无效");
}
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) {
// auth() 中间件已验证登录态
const user = ctx.state.user;
if (!user) {
return ResponseUtil.unauthorized(ctx, "用户未登录或 token 无效");
}
if (!("wxapp-escort-admin" in (user.app || {}))) {
return ResponseUtil.unauthorized(ctx, "用户无管理员权限");
}
try {
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, total] = await Promise.all([
DBModel.User.find(filter).skip((page - 1) * pageSize).limit(pageSize),
DBModel.User.countDocuments(filter),
]);
const safeUsers = users.map((u) => sanitizeUser(u));
return ResponseUtil.success(ctx, { users: safeUsers, total, page, pageSize }, "获取用户列表成功");
} catch (err) {
return ResponseUtil.internalError(ctx, err.message);
}
}
// 微信登录
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 } = value;
let app = config.app[appId];
if (!app) {
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
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 sessionData = await wxSessionRes.json();
if (sessionData.errcode) {
return ResponseUtil.error(ctx, `微信接口错误: ${sessionData.errmsg}`, null, 400);
}
const { openid } = sessionData;
if (!openid) {
return ResponseUtil.error(ctx, "微信登录失败,未获取到 openid", null, 400);
}
let key = `app.${appId}.wxopenid`;
let user = await DBModel.User.findOne({ [key]: openid });
if (!user) {
const mobile = verifiedPhoneNumber || phoneNumber;
if (!mobile) {
return ResponseUtil.badRequest(ctx, "缺少手机号");
}
const existingUser = await DBModel.User.findOne({ "profile.mobile": mobile });
if (existingUser) {
// 已有账号:仅允许服务端验证过的手机号绑定微信,防止账号接管
if (!verifiedPhoneNumber) {
return ResponseUtil.error(ctx, "该手机号已注册,请先使用手机号登录后再绑定微信", null, 409);
}
user = existingUser;
} else {
// 新建用户
const newUser = {
profile: { name: name || mobile, mobile },
status: { account: "normal" },
app: {},
};
newUser.app[appId] = { role: ["user"], wxopenid: openid };
user = await DBModel.User.setUser(newUser);
}
}
if (user) {
// 绑定/更新 openid;不覆盖已有用户的手机号
if (!(appId in user.app)) {
user.app[appId] = { role: ["user"], wxopenid: openid };
} else {
user.app[appId].wxopenid = openid;
}
} else {
return ResponseUtil.internalError(ctx, "用户不存在");
}
// 更新Token(哈希入库)
user.security.lastLoginIp = ctx.ip || ctx.request.ip;
const { raw } = await this.issueToken(user);
return ResponseUtil.success(ctx, { user: this.buildLoginResponse(user, raw) }, "登录成功");
} catch (err) {
return ResponseUtil.internalError(ctx, err.message);
}
}
// 获取微信的手机号码
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 } = value;
let app = config.app[appId];
if (!app) {
return ResponseUtil.badRequest(ctx, `未配置 appId: ${appId}`);
}
// 获取access_token(带缓存的封装,避免耗尽微信每日配额)
const accessToken = await getWxAccessToken(app);
// 获取phoneNumber
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: code })
});
const phoneData = await phoneRes.json();
if (phoneData.errcode) {
return ResponseUtil.error(ctx, `获取手机号失败: ${phoneData.errmsg}`, null, 400);
}
const phoneNumber = phoneData.phone_info?.phoneNumber;
return ResponseUtil.success(ctx, { phoneNumber }, "获取手机号成功");
} catch (err) {
return ResponseUtil.internalError(ctx, err.message);
}
}
}
export { HandlerUser };