450 lines
15 KiB
JavaScript
450 lines
15 KiB
JavaScript
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 {
|
||
// 生成 token(DB 中只存哈希,原始 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 };
|