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