diff --git a/.gitignore b/.gitignore index e63b985..874df1b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /node_modules/ /logs/ +conf.json diff --git a/app.js b/app.js index 83eb0f9..fc4e6ed 100644 --- a/app.js +++ b/app.js @@ -34,7 +34,7 @@ class APP { setupRoutes() { this.app.use(async (ctx, next) => { if (ctx.path === '/') { - ResponseUtil.success(ctx, { name: 'attendant-api', version: '1.0.0' }); + ResponseUtil.success(ctx, { name: 'user-api', version: '1.0.0' }); return; } await next(); diff --git a/handler/users.js b/handler/users.js index 39b3e00..bf6a26c 100644 --- a/handler/users.js +++ b/handler/users.js @@ -1,10 +1,31 @@ import Joi from 'joi'; import { DBModel } from "../models/index.js"; import ResponseUtil from "../utils/api_response.js"; -import { hashPassword, verifyPassword } from "../utils/crypto.js"; -import { sanitizeUser, extractToken } from "../middleware/auth.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({ @@ -45,6 +66,9 @@ const signinSchema = Joi.object({ 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), }); @@ -54,8 +78,10 @@ const wxGetPhoneSchema = Joi.object({ appId: Joi.string().required().messages({ 'any.required': '缺少 appId' }), }); +const LOCK_AFTER_FAILED_ATTEMPTS = 10; + class HandlerUser { - // 生成 token + // 生成 token(DB 中只存哈希,原始 token 仅在登录响应中返回一次) async genToken(uid) { const crypto = await import("crypto"); const hash = crypto.createHash("sha256"); @@ -63,6 +89,25 @@ class HandlerUser { 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 }); @@ -109,13 +154,10 @@ class HandlerUser { 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(); + // 生成 token(哈希入库,响应附原始 token) + const { raw } = await this.issueToken(user); - return ResponseUtil.success(ctx, { user: sanitizeUser(user) }, "注册成功"); + return ResponseUtil.success(ctx, { user: this.buildLoginResponse(user, raw) }, "注册成功"); } catch (err) { return ResponseUtil.internalError(ctx, err.message); } @@ -137,14 +179,24 @@ class HandlerUser { 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) { - // 记录失败登录次数 - 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, "密码错误"); } @@ -159,15 +211,11 @@ class HandlerUser { 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(); + // 生成/更新 token(哈希入库) 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) { return ResponseUtil.internalError(ctx, err.message); } @@ -175,12 +223,8 @@ class HandlerUser { // 退出登录 async signout(ctx) { - const token = extractToken(ctx); - if (!token) { - return ResponseUtil.badRequest(ctx, "缺少 token"); - } - - const user = await DBModel.User.findOne({ "security.token": token }); + // auth({required:false}) 中间件已验证登录态 + const user = ctx.state.user; if (user) { user.security.token = null; user.security.tokenExpiry = null; @@ -192,7 +236,12 @@ class HandlerUser { // 获取用户信息 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 || {}; if (userId) { const user = await DBModel.User.findOne({ _id: userId }); @@ -202,40 +251,17 @@ class HandlerUser { 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) }, "获取用户信息成功"); + return ResponseUtil.badRequest(ctx, "缺少 token 或 userId"); } // 更新用户信息 async updateUser(ctx) { - const token = extractToken(ctx); - if (!token) { - return ResponseUtil.badRequest(ctx, "缺少 token"); - } - - const user = await DBModel.User.findOne({ "security.token": token }); + // auth() 中间件已验证登录态 + const user = ctx.state.user; 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) { @@ -255,33 +281,28 @@ class HandlerUser { // 获取用户列表 async userList(ctx) { - const token = extractToken(ctx); - if (!token) { - return ResponseUtil.badRequest(ctx, "缺少 token"); - } - - const user = await DBModel.User.findOne({ "security.token": token }); + // auth() 中间件已验证登录态 + const user = ctx.state.user; if (!user) { return ResponseUtil.unauthorized(ctx, "用户未登录或 token 无效"); } - if (!('wxapp-escort-admin' in user.app)) { + 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 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 } }) - .skip((page - 1) * pageSize) - .limit(pageSize); + 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 }, "获取用户列表成功"); + const safeUsers = users.map((u) => sanitizeUser(u)); + return ResponseUtil.success(ctx, { users: safeUsers, total, page, pageSize }, "获取用户列表成功"); } catch (err) { return ResponseUtil.internalError(ctx, err.message); } @@ -302,6 +323,29 @@ class HandlerUser { 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); @@ -315,18 +359,26 @@ class HandlerUser { return ResponseUtil.error(ctx, "微信登录失败,未获取到 openid", null, 400); } - // 使用openid和phoneNumber查询用户 let key = `app.${appId}.wxopenid`; let user = await DBModel.User.findOne({ [key]: openid }); + if (!user) { - if (!phoneNumber) { + const mobile = verifiedPhoneNumber || phoneNumber; + if (!mobile) { return ResponseUtil.badRequest(ctx, "缺少手机号"); } - user = await DBModel.User.findOne({ "profile.mobile": phoneNumber }); - if (!user) { + 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 || phoneNumber, mobile: phoneNumber }, + profile: { name: name || mobile, mobile }, status: { account: "normal" }, app: {}, }; @@ -336,26 +388,21 @@ class HandlerUser { } if (user) { - if (phoneNumber && phoneNumber.length > 0 && user.profile.mobile !== phoneNumber) { - user.profile.mobile = phoneNumber; - } + // 绑定/更新 openid;不覆盖已有用户的手机号 if (!(appId in user.app)) { user.app[appId] = { role: ["user"], wxopenid: openid }; + } else { + user.app[appId].wxopenid = openid; } - user.app[appId].wxopenid = openid; } else { 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); - user.security.lastLoginAt = new Date(); + // 更新Token(哈希入库) 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) { return ResponseUtil.internalError(ctx, err.message); } @@ -376,17 +423,11 @@ class HandlerUser { return ResponseUtil.badRequest(ctx, `未配置 appId: ${appId}`); } - // 获取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 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 失败"); - } + // 获取access_token(带缓存的封装,避免耗尽微信每日配额) + const accessToken = await getWxAccessToken(app); // 获取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, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/index.js b/index.js index 4c4570b..2ea6b9a 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,6 @@ import { APP } from './app.js' +import config from './conf.json' with { type: 'json' }; // HTTP server const koaApp = new APP(); -koaApp.start(9010); +koaApp.start(parseInt(process.env.PORT || config.port || "9010")); diff --git a/middleware/auth.js b/middleware/auth.js index 901cc5d..770c28d 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -1,5 +1,6 @@ import { DBModel } from '../models/index.js'; import ResponseUtil from '../utils/api_response.js'; +import { hashToken } from '../utils/crypto.js'; /** * 从请求中提取 token(优先 Authorization header) @@ -17,6 +18,24 @@ function extractToken(ctx) { || 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 * 可选参数: @@ -37,7 +56,7 @@ function auth(options = {}) { return ResponseUtil.unauthorized(ctx, '缺少认证 token'); } - const user = await DBModel.User.findOne({ 'security.token': token }); + const user = await findUserByToken(token); if (!user) { return ResponseUtil.unauthorized(ctx, '用户未登录或 token 无效'); } @@ -69,15 +88,18 @@ function auth(options = {}) { } /** - * 返回用户安全对象(去除密码等敏感字段) + * 返回用户安全对象(去除密码、token 等敏感字段) + * 注意:登录/注册响应中的 token 由 handler 单独附加 */ function sanitizeUser(user) { const obj = user.toObject ? user.toObject() : { ...user }; delete obj.security?.passwd; delete obj.security?.passwdSalt; + delete obj.security?.token; + delete obj.security?.tokenExpiry; delete obj.security?.passwordResetToken; delete obj.security?.passwordResetExpiry; return obj; } -export { auth, extractToken, sanitizeUser }; +export { auth, extractToken, sanitizeUser, findUserByToken }; diff --git a/models/index.js b/models/index.js index 9b8301a..2fb22f7 100644 --- a/models/index.js +++ b/models/index.js @@ -9,8 +9,6 @@ class MongoDBSchema { constructor() { this.dbConnection = null; this.User = null; - this.EscortRecord = null; - this.Hospital = null; } init() { @@ -18,22 +16,22 @@ class MongoDBSchema { this.dbConnection = mongoose.createConnection(config.mongodb.str, config.mongodb.option); this.dbConnection.on("error", () => { - logger.error.bind(logger, "...mongodb connect error ...") + logger.error("...mongodb connect error ..."); }); 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", () => - logger.warn("Mongodb: " + config.mongodb.str + " disconnected") + logger.warn(`Mongodb: ${config.mongodb.host}/${config.mongodb.option.dbName} disconnected`) ); 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", () => - logger.warn("Mongodb: " + config.mongodb.str + " disconnecting") + logger.warn(`Mongodb: ${config.mongodb.host}/${config.mongodb.option.dbName} disconnecting`) ); 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) diff --git a/models/schema/user.js b/models/schema/user.js index c35b3a5..3e1a6c6 100644 --- a/models/schema/user.js +++ b/models/schema/user.js @@ -88,30 +88,6 @@ const UserSchema = mongoose.Schema( } ); -/** - * 根据用户标识信息查找用户 - * - * 支持通过手机号、邮箱、token或微信unionid查找用户 - * - * @param {Object} _user - 包含查找条件的用户对象 - * @param {Function} cb - 可选的回调函数 - * @returns {Promise} 找到的用户对象或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); -}; - /** * 设置/更新用户信息 * diff --git a/routes/index.js b/routes/index.js index bf4c111..9b12dc4 100644 --- a/routes/index.js +++ b/routes/index.js @@ -1,6 +1,7 @@ import Router from 'koa-router'; import { HandlerUser } from '../handler/users.js'; import { rateLimit } from '../middleware/ratelimit.js'; +import { auth } from '../middleware/auth.js'; class ApiRouter { constructor() { @@ -17,11 +18,11 @@ class ApiRouter { 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('/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('/wxgetphonenumber', this.handler.wxGetPhoneNumber.bind(this.handler)); + userRouter.post('/signout', auth({ required: false }), this.handler.signout.bind(this.handler)); + userRouter.post('/userInfo', auth({ required: false }), this.handler.userInfo.bind(this.handler)); + userRouter.post('/update', auth(), this.handler.updateUser.bind(this.handler)); + userRouter.post('/list', auth(), this.handler.userList.bind(this.handler)); + userRouter.post('/wxgetphonenumber', rateLimit(10, 60_000), this.handler.wxGetPhoneNumber.bind(this.handler)); this.router.use(userRouter.routes()); diff --git a/utils/crypto.js b/utils/crypto.js index ae1a7c2..2be9551 100644 --- a/utils/crypto.js +++ b/utils/crypto.js @@ -9,6 +9,14 @@ async function hashPassword(passwd) { 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 旧密码(渐进式迁移) @@ -33,4 +41,4 @@ async function verifyPassword(passwd, storedHash, salt) { }; } -export { hashPassword, verifyPassword }; +export { hashPassword, verifyPassword, hashToken };