完善了安全功能
This commit is contained in:
362
handler/users.js
362
handler/users.js
@@ -1,42 +1,79 @@
|
||||
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 config from "../conf.json" with { type: "json" };
|
||||
|
||||
class HandlerUser {
|
||||
constructor() {
|
||||
}
|
||||
// 输入校验 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' }),
|
||||
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' }),
|
||||
});
|
||||
|
||||
class HandlerUser {
|
||||
// 生成 token
|
||||
async genToken(uid) {
|
||||
const crypto = await import("crypto");
|
||||
const hash = crypto.createHash("md5");
|
||||
const hash = crypto.createHash("sha256");
|
||||
hash.update(uid + Date.now() + Math.random());
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
checkUserValid(user) {
|
||||
const isTokenValid = user.security.token &&
|
||||
user.security.tokenExpiry &&
|
||||
new Date() < user.security.tokenExpiry;
|
||||
return isTokenValid;
|
||||
}
|
||||
|
||||
// 用户注册
|
||||
async register(ctx) {
|
||||
try {
|
||||
const { userInfo } = ctx.request.body;
|
||||
if (!userInfo) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少用户信息");
|
||||
}
|
||||
const { error, value } = registerSchema.validate(ctx.request.body, { abortEarly: false });
|
||||
if (error) {
|
||||
return ResponseUtil.badRequest(ctx, error.details[0].message);
|
||||
}
|
||||
|
||||
const mobile = userInfo.profile?.mobile;
|
||||
const passwd = userInfo.security?.passwd;
|
||||
if (!mobile) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少手机号");
|
||||
}
|
||||
if (!passwd) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少密码");
|
||||
}
|
||||
try {
|
||||
const { userInfo } = value;
|
||||
const mobile = userInfo.profile.mobile;
|
||||
const passwd = userInfo.security.passwd;
|
||||
|
||||
// 检查手机号是否已注册
|
||||
const existingUser = await DBModel.User.findOne({ "profile.mobile": mobile });
|
||||
@@ -44,21 +81,13 @@ class HandlerUser {
|
||||
return ResponseUtil.error(ctx, "手机号已注册", null, 409);
|
||||
}
|
||||
|
||||
// 生成随机盐值
|
||||
const crypto = await import("crypto");
|
||||
const salt = crypto.randomBytes(8).toString("hex");
|
||||
// bcrypt 加密密码
|
||||
const encryptedPasswd = await hashPassword(passwd);
|
||||
|
||||
// MD5 加密密码
|
||||
const hash = crypto.createHash("md5");
|
||||
hash.update(passwd + salt);
|
||||
const encryptedPasswd = hash.digest("hex");
|
||||
|
||||
// 构建新用户,结构同 UserSchema
|
||||
const newUser = {
|
||||
profile: userInfo.profile,
|
||||
security: {
|
||||
passwd: encryptedPasswd,
|
||||
passwdSalt: salt,
|
||||
},
|
||||
location: {
|
||||
province: userInfo.location?.province || '',
|
||||
@@ -67,8 +96,7 @@ class HandlerUser {
|
||||
},
|
||||
addresses: userInfo.addresses || [],
|
||||
social: {
|
||||
wechat: {
|
||||
}
|
||||
wechat: {}
|
||||
},
|
||||
status: {
|
||||
account: "normal",
|
||||
@@ -87,12 +115,7 @@ class HandlerUser {
|
||||
user.security.tokenExpiry = new Date(Date.now() + 15 * 24 * 60 * 60 * 1000);
|
||||
await user.save();
|
||||
|
||||
// 安全起见删除密码相关字段
|
||||
const safeUser = user.toObject();
|
||||
delete safeUser.security?.passwd;
|
||||
delete safeUser.security?.passwdSalt;
|
||||
|
||||
return ResponseUtil.success(ctx, { user: safeUser }, "注册成功");
|
||||
return ResponseUtil.success(ctx, { user: sanitizeUser(user) }, "注册成功");
|
||||
} catch (err) {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
}
|
||||
@@ -100,14 +123,13 @@ class HandlerUser {
|
||||
|
||||
// 手机号码登录
|
||||
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 } = ctx.request.body;
|
||||
if (!mobile) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少手机号");
|
||||
}
|
||||
if (!passwd) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少密码");
|
||||
}
|
||||
const { mobile, passwd } = value;
|
||||
|
||||
// 查找用户
|
||||
let user = await DBModel.User.findOne({ "profile.mobile": mobile });
|
||||
@@ -115,39 +137,37 @@ class HandlerUser {
|
||||
return ResponseUtil.unauthorized(ctx, "用户不存在");
|
||||
}
|
||||
|
||||
// 校验密码(MD5 加密比对)
|
||||
const crypto = await import("crypto");
|
||||
const hash = crypto.createHash("md5");
|
||||
hash.update(passwd + (user.security.passwdSalt || ""));
|
||||
const encryptedPasswd = hash.digest("hex");
|
||||
// 校验密码(支持 bcrypt 和 MD5 渐进式迁移)
|
||||
const { valid, needsUpgrade } = await verifyPassword(
|
||||
passwd, user.security.passwd, user.security.passwdSalt
|
||||
);
|
||||
|
||||
if (user.security.passwd !== encryptedPasswd) {
|
||||
if (!valid) {
|
||||
// 记录失败登录次数
|
||||
await DBModel.User.incrementFailedLoginAttempts(user._id);
|
||||
return ResponseUtil.unauthorized(ctx, "密码错误");
|
||||
}
|
||||
|
||||
// 检查账户状态
|
||||
if (user.status.account === "lock") {
|
||||
return ResponseUtil.forbidden(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
|
||||
const isTokenValid = user.security.token &&
|
||||
user.security.tokenExpiry &&
|
||||
new Date() < user.security.tokenExpiry;
|
||||
|
||||
if (!isTokenValid) {
|
||||
const token = await this.genToken(user._id.toString());
|
||||
user.security.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;
|
||||
await user.save();
|
||||
|
||||
// 安全起见删除密码相关字段
|
||||
const safeUser = user.toObject();
|
||||
delete safeUser.security?.passwd;
|
||||
delete safeUser.security?.passwdSalt;
|
||||
|
||||
return ResponseUtil.success(ctx, { user: safeUser }, "登录成功");
|
||||
return ResponseUtil.success(ctx, { user: sanitizeUser(user) }, "登录成功");
|
||||
} catch (err) {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
}
|
||||
@@ -155,11 +175,7 @@ class HandlerUser {
|
||||
|
||||
// 退出登录
|
||||
async signout(ctx) {
|
||||
const token = ctx.request.body?.token
|
||||
|| ctx.request.query?.token
|
||||
|| ctx.header?.authorization
|
||||
|| ctx.header?.token;
|
||||
|
||||
const token = extractToken(ctx);
|
||||
if (!token) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少 token");
|
||||
}
|
||||
@@ -176,84 +192,51 @@ class HandlerUser {
|
||||
|
||||
// 获取用户信息
|
||||
async userInfo(ctx) {
|
||||
try {
|
||||
const { token, userId } = ctx.request.body;
|
||||
if (!token && !userId) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少 token 或 userId");
|
||||
}
|
||||
|
||||
let user = null;
|
||||
if (token) {
|
||||
user = await DBModel.User.findOne({ "security.token": token });
|
||||
}
|
||||
else {
|
||||
user = await DBModel.User.findOne({ "_id": userId });
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return ResponseUtil.unauthorized(ctx, "用户未登录或 token 无效");
|
||||
}
|
||||
|
||||
const isTokenValid = user.security.token &&
|
||||
user.security.tokenExpiry &&
|
||||
new Date() < user.security.tokenExpiry;
|
||||
if (!isTokenValid) {
|
||||
return ResponseUtil.unauthorized(ctx, "登录已过期,请重新登录");
|
||||
}
|
||||
|
||||
// 安全起见删除密码相关字段
|
||||
delete user.security.passwd;
|
||||
delete user.security.passwdSalt;
|
||||
|
||||
return ResponseUtil.success(ctx, { user }, "获取用户信息成功");
|
||||
} catch (err) {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
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) }, "获取用户信息成功");
|
||||
}
|
||||
|
||||
// update user
|
||||
// 更新用户信息
|
||||
async updateUser(ctx) {
|
||||
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, "登录已过期,请重新登录");
|
||||
}
|
||||
|
||||
try {
|
||||
const userInfo = ctx.request.body;
|
||||
if (!userInfo) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少用户信息");
|
||||
}
|
||||
|
||||
// 从 token 获取当前用户
|
||||
const token = ctx.request.body?.token
|
||||
|| ctx.request.query?.token
|
||||
|| ctx.header?.authorization
|
||||
|| ctx.header?.token;
|
||||
|
||||
if (!token) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少 token");
|
||||
}
|
||||
|
||||
const user = await DBModel.User.findOne({ "security.token": token });
|
||||
if (!user) {
|
||||
return ResponseUtil.unauthorized(ctx, "用户未登录或 token 无效");
|
||||
}
|
||||
|
||||
// 检查 token 是否过期
|
||||
if (user.security.tokenExpiry && new Date() > user.security.tokenExpiry) {
|
||||
return ResponseUtil.unauthorized(ctx, "登录已过期,请重新登录");
|
||||
}
|
||||
|
||||
const updatedUser = await DBModel.User.updateFromUserInfo(
|
||||
user._id,
|
||||
userInfo
|
||||
);
|
||||
|
||||
const updatedUser = await DBModel.User.updateFromUserInfo(user._id, userInfo);
|
||||
if (!updatedUser) {
|
||||
return ResponseUtil.internalError(ctx, "更新用户失败");
|
||||
}
|
||||
|
||||
// 安全起见删除密码相关字段
|
||||
const safeUser = updatedUser.toObject();
|
||||
delete safeUser.security?.passwd;
|
||||
delete safeUser.security?.passwdSalt;
|
||||
|
||||
return ResponseUtil.success(ctx, { user: safeUser }, "更新成功");
|
||||
return ResponseUtil.success(ctx, { user: sanitizeUser(updatedUser) }, "更新成功");
|
||||
} catch (err) {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
}
|
||||
@@ -261,43 +244,33 @@ 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 });
|
||||
if (!user) {
|
||||
return ResponseUtil.unauthorized(ctx, "用户未登录或 token 无效");
|
||||
}
|
||||
|
||||
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;
|
||||
// 从 token 获取当前用户
|
||||
const token = ctx.request.body?.token
|
||||
|| ctx.request.query?.token
|
||||
|| ctx.header?.authorization
|
||||
|| ctx.header?.token;
|
||||
|
||||
// 通过token获取用户
|
||||
const user = await DBModel.User.findOne({ "security.token": token });
|
||||
if (!user) {
|
||||
return ResponseUtil.unauthorized(ctx, "用户未登录或 token 无效");
|
||||
}
|
||||
|
||||
if (!('wxapp-escort-admin' in user.app)) {
|
||||
return ResponseUtil.unauthorized(ctx, "用户无管理员权限");
|
||||
}
|
||||
|
||||
const isTokenValid = user.security.token &&
|
||||
user.security.tokenExpiry &&
|
||||
new Date() < user.security.tokenExpiry;
|
||||
if (!isTokenValid) {
|
||||
return ResponseUtil.unauthorized(ctx, "登录已过期,请重新登录");
|
||||
}
|
||||
|
||||
// 查询所有user.app包含wxapp-escort的用户
|
||||
const users = await DBModel.User.find({ "app.wxapp-escort": { $exists: true } })
|
||||
.skip((page - 1) * pageSize)
|
||||
.limit(pageSize);
|
||||
|
||||
// 安全起见删除密码相关字段
|
||||
users.forEach(u => {
|
||||
delete u.security.passwd;
|
||||
delete u.security.passwdSalt;
|
||||
});
|
||||
|
||||
return ResponseUtil.success(ctx, { users }, "获取用户列表成功");
|
||||
const safeUsers = users.map(u => sanitizeUser(u));
|
||||
return ResponseUtil.success(ctx, { users: safeUsers }, "获取用户列表成功");
|
||||
} catch (err) {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
}
|
||||
@@ -305,13 +278,13 @@ class HandlerUser {
|
||||
|
||||
// 微信登录
|
||||
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 } = ctx.request.body;
|
||||
if (!code || !appId) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少微信登录凭证 code 或 appId");
|
||||
}
|
||||
|
||||
|
||||
const { code, phoneNumber, name, appId } = value;
|
||||
|
||||
let app = config.app[appId];
|
||||
if (!app) {
|
||||
@@ -342,17 +315,15 @@ class HandlerUser {
|
||||
user = await DBModel.User.findOne({ "profile.mobile": phoneNumber });
|
||||
if (!user) {
|
||||
const newUser = {
|
||||
profile: { name: name || phoneNumber, mobile: phoneNumber, },
|
||||
status: { account: "normal", },
|
||||
profile: { name: name || phoneNumber, mobile: phoneNumber },
|
||||
status: { account: "normal" },
|
||||
app: {},
|
||||
};
|
||||
newUser.app[appId] = { role: ["user"], wxopenid: openid };
|
||||
|
||||
user = await DBModel.User.setUser(newUser);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
if (user) {
|
||||
if (phoneNumber && phoneNumber.length > 0 && user.profile.mobile !== phoneNumber) {
|
||||
user.profile.mobile = phoneNumber;
|
||||
@@ -366,22 +337,14 @@ class HandlerUser {
|
||||
}
|
||||
|
||||
// 更新Token
|
||||
const isTokenValid = user.security.token &&
|
||||
user.security.tokenExpiry &&
|
||||
new Date() < user.security.tokenExpiry;
|
||||
|
||||
if (!isTokenValid) {
|
||||
const token = await this.genToken(user._id.toString());
|
||||
user.security.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;
|
||||
await user.save();
|
||||
|
||||
// 安全起见删除密码相关字段
|
||||
delete user.security.passwd;
|
||||
delete user.security.passwdSalt;
|
||||
|
||||
return ResponseUtil.success(ctx, { user }, "登录成功");
|
||||
return ResponseUtil.success(ctx, { user: sanitizeUser(user) }, "登录成功");
|
||||
} catch (err) {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
}
|
||||
@@ -389,11 +352,13 @@ class HandlerUser {
|
||||
|
||||
// 获取微信的手机号码
|
||||
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 } = ctx.request.body;
|
||||
if (!code || !appId) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少手机号凭证 code 或 appId");
|
||||
}
|
||||
const { code, appId } = value;
|
||||
|
||||
let app = config.app[appId];
|
||||
if (!app) {
|
||||
@@ -421,7 +386,6 @@ class HandlerUser {
|
||||
return ResponseUtil.error(ctx, `获取手机号失败: ${phoneData.errmsg}`, null, 400);
|
||||
}
|
||||
|
||||
// 从上图获取手机号
|
||||
const phoneNumber = phoneData.phone_info?.phoneNumber;
|
||||
return ResponseUtil.success(ctx, { phoneNumber }, "获取手机号成功");
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user