diff --git a/server/src/middleware/auth.js b/server/src/middleware/auth.js index 8c9db15..b39e2c3 100755 --- a/server/src/middleware/auth.js +++ b/server/src/middleware/auth.js @@ -1,158 +1,158 @@ -const jwt = require('jsonwebtoken') -const { Admin, User } = require('../models') - -/** - * JWT 认证中间件(管理员) - */ -const authMiddleware = async (req, res, next) => { - try { - // 从 header 获取 token - const authHeader = req.header('Authorization') - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return res.status(401).json({ - success: false, - error: '未提供认证令牌' - }) - } - - const token = authHeader.replace('Bearer ', '') - - // 验证 token - const decoded = jwt.verify(token, process.env.JWT_SECRET) - - // 查找管理员 - const admin = await Admin.findById(decoded.adminId) - if (!admin) { - return res.status(401).json({ - success: false, - error: '管理员不存在' - }) - } - - // 检查状态 - if (admin.status !== 'active') { - return res.status(403).json({ - success: false, - error: '管理员账户已禁用' - }) - } - - // 将管理员信息附加到请求对象 - req.admin = admin - next() - } catch (error) { - if (error.name === 'JsonWebTokenError') { - return res.status(401).json({ - success: false, - error: '无效的认证令牌' - }) - } - if (error.name === 'TokenExpiredError') { - return res.status(401).json({ - success: false, - error: '认证令牌已过期' - }) - } - next(error) - } -} - -/** - * 权限检查中间件 - * @param {string} permission - 需要的权限 - */ -const requirePermission = (permission) => { - return (req, res, next) => { - if (!req.admin) { - return res.status(401).json({ - success: false, - error: '未认证' - }) - } - - if (!req.admin.hasPermission(permission)) { - return res.status(403).json({ - success: false, - error: '权限不足' - }) - } - - next() - } -} - -/** - * 小程序用户 JWT 认证中间件 - */ -const userAuthMiddleware = async (req, res, next) => { - try { - const authHeader = req.header('Authorization') - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return res.status(401).json({ - success: false, - error: '未提供认证令牌' - }) - } - - const token = authHeader.replace('Bearer ', '') - - const decoded = jwt.verify(token, process.env.JWT_SECRET) - - // 兼容两种token格式:包含userId或openid - let user = null - if (decoded.userId) { - user = await User.findById(decoded.userId) - } else if (decoded.openid) { - user = await User.findOne({ openid: decoded.openid }) - } - - if (!user) { - return res.status(401).json({ - success: false, - error: '用户不存在或令牌无效' - }) - } - - req.user = user - // 优先使用 openid,如果没有则使用 userId 作为标识 - req.openid = user.openid || user._id.toString() - next() - } catch (error) { - if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') { - return res.status(401).json({ - success: false, - error: '认证令牌无效或已过期' - }) - } - next(error) - } -} - -/** - * 可选的用户认证中间件(不强制要求登录) - */ -const optionalAuthMiddleware = async (req, res, next) => { - try { - const authHeader = req.header('Authorization') - if (authHeader && authHeader.startsWith('Bearer ')) { - const token = authHeader.replace('Bearer ', '') - const decoded = jwt.verify(token, process.env.JWT_SECRET) - const user = await User.findById(decoded.userId) - if (user) { - req.user = user - req.openid = user.openid - } - } - next() - } catch (error) { - // 即使认证失败也继续,只是不附加用户信息 - next() - } -} - -module.exports = { - authMiddleware, - requirePermission, - userAuthMiddleware, - optionalAuthMiddleware -} +const jwt = require('jsonwebtoken') +const { Admin, User } = require('../models') + +/** + * JWT 认证中间件(管理员) + */ +const authMiddleware = async (req, res, next) => { + try { + // 从 header 获取 token + const authHeader = req.header('Authorization') + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ + success: false, + error: '未提供认证令牌' + }) + } + + const token = authHeader.replace('Bearer ', '') + + // 验证 token + const decoded = jwt.verify(token, process.env.JWT_SECRET) + + // 查找管理员 + const admin = await Admin.findById(decoded.adminId) + if (!admin) { + return res.status(401).json({ + success: false, + error: '管理员不存在' + }) + } + + // 检查状态 + if (admin.status !== 'active') { + return res.status(403).json({ + success: false, + error: '管理员账户已禁用' + }) + } + + // 将管理员信息附加到请求对象 + req.admin = admin + next() + } catch (error) { + if (error.name === 'JsonWebTokenError') { + return res.status(401).json({ + success: false, + error: '无效的认证令牌' + }) + } + if (error.name === 'TokenExpiredError') { + return res.status(401).json({ + success: false, + error: '认证令牌已过期' + }) + } + next(error) + } +} + +/** + * 权限检查中间件 + * @param {string} permission - 需要的权限 + */ +const requirePermission = (permission) => { + return (req, res, next) => { + if (!req.admin) { + return res.status(401).json({ + success: false, + error: '未认证' + }) + } + + if (!req.admin.hasPermission(permission)) { + return res.status(403).json({ + success: false, + error: '权限不足' + }) + } + + next() + } +} + +/** + * 小程序用户 JWT 认证中间件 + */ +const userAuthMiddleware = async (req, res, next) => { + try { + const authHeader = req.header('Authorization') + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ + success: false, + error: '未提供认证令牌' + }) + } + + const token = authHeader.replace('Bearer ', '') + + const decoded = jwt.verify(token, process.env.JWT_SECRET) + + // 兼容两种token格式:包含userId或openid + let user = null + if (decoded.userId) { + user = await User.findById(decoded.userId).select('+sessionKey') // vpay 签名需要 + } else if (decoded.openid) { + user = await User.findOne({ openid: decoded.openid }) + } + + if (!user) { + return res.status(401).json({ + success: false, + error: '用户不存在或令牌无效' + }) + } + + req.user = user + // 优先使用 openid,如果没有则使用 userId 作为标识 + req.openid = user.openid || user._id.toString() + next() + } catch (error) { + if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') { + return res.status(401).json({ + success: false, + error: '认证令牌无效或已过期' + }) + } + next(error) + } +} + +/** + * 可选的用户认证中间件(不强制要求登录) + */ +const optionalAuthMiddleware = async (req, res, next) => { + try { + const authHeader = req.header('Authorization') + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.replace('Bearer ', '') + const decoded = jwt.verify(token, process.env.JWT_SECRET) + const user = await User.findById(decoded.userId) + if (user) { + req.user = user + req.openid = user.openid + } + } + next() + } catch (error) { + // 即使认证失败也继续,只是不附加用户信息 + next() + } +} + +module.exports = { + authMiddleware, + requirePermission, + userAuthMiddleware, + optionalAuthMiddleware +} diff --git a/server/src/models/User.js b/server/src/models/User.js index d44511b..65ac76b 100755 --- a/server/src/models/User.js +++ b/server/src/models/User.js @@ -160,7 +160,7 @@ const UserSchema = new Schema({ default: Date.now }, // vpay 用户态签名必需(code2Session 返回,有过期时间,过期后重登录刷新) - sessionKey: { type: String, default: '' }, + sessionKey: { type: String, default: '', select: false }, // [安全] 默认查询不返回(支付签名凭据) vpayCoins: { type: Number, default: 0 }, lastLoginAt: { type: Date, diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index c8f61db..ffc7e6e 100755 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -6,6 +6,7 @@ const { ipLocation } = require('../utils/iploc') const { authMiddleware, requirePermission } = require('../middleware/auth') const ApiResponse = require('../utils/response') const logger = require('../utils/logger') +const { escapeRegExp } = require('../utils/escapeRegex') // 所有管理路由都需要认证 router.use(authMiddleware) @@ -138,7 +139,7 @@ router.get('/users', requirePermission('users:read'), async (req, res) => { const query = {} if (search) { - query.nickName = { $regex: search, $options: 'i' } + query.nickName = { $regex: escapeRegExp(search), $options: 'i' } } if (dimCompleted) { @@ -743,7 +744,7 @@ router.get('/ai-chat/quota-list', requirePermission('user:read'), async (req, re try { const { page = 1, pageSize = 10, keyword = '' } = req.query - const query = keyword ? { openid: { $regex: keyword, $options: 'i' } } : {} + const query = keyword ? { openid: { $regex: escapeRegExp(keyword), $options: 'i' } } : {} const [list, total] = await Promise.all([ AIChatQuota.find(query) @@ -854,9 +855,9 @@ router.get('/feedback', requirePermission('feedback:read'), async (req, res) => if (priority) query.priority = priority if (search) { query.$or = [ - { title: { $regex: search, $options: 'i' } }, - { content: { $regex: search, $options: 'i' } }, - { userName: { $regex: search, $options: 'i' } } + { title: { $regex: escapeRegExp(search), $options: 'i' } }, + { content: { $regex: escapeRegExp(search), $options: 'i' } }, + { userName: { $regex: escapeRegExp(search), $options: 'i' } } ] } @@ -1124,7 +1125,7 @@ router.get('/login-logs', requirePermission('user:read'), async (req, res) => { router.get('/trends', requirePermission('trend:read'), async (req, res) => { try { const { page = 1, limit = 20, keyword = '' } = req.query - const query = keyword ? { title: { $regex: keyword, $options: 'i' } } : {} + const query = keyword ? { title: { $regex: escapeRegExp(keyword), $options: 'i' } } : {} const total = await Trend.countDocuments(query) const items = await Trend.find(query).sort({ newsDate: -1, createdAt: -1 }) .skip((parseInt(page) - 1) * parseInt(limit)).limit(parseInt(limit)).lean() @@ -1271,7 +1272,7 @@ router.get('/vpay/orders', requirePermission('orders:read'), async (req, res) => const { page = 1, limit = 20, status = '', keyword = '' } = req.query const query = {} if (status) query.status = status - if (keyword) query.orderNo = { $regex: keyword, $options: 'i' } + if (keyword) query.orderNo = { $regex: escapeRegExp(keyword), $options: 'i' } const total = await Order.countDocuments(query) const items = await Order.find(query) .sort({ createdAt: -1 }) @@ -1432,7 +1433,7 @@ router.get('/vpay/ledger', requirePermission('orders:read'), async (req, res) => const { page = 1, limit = 20, type = '', keyword = '' } = req.query const query = {} if (type) query.type = type - if (keyword) query.openid = { $regex: keyword, $options: 'i' } + if (keyword) query.openid = { $regex: escapeRegExp(keyword), $options: 'i' } const total = await CoinLedger.countDocuments(query) const items = await CoinLedger.find(query) .sort({ createdAt: -1 }) diff --git a/server/src/routes/aiModel.js b/server/src/routes/aiModel.js index f33d550..cc64c4e 100755 --- a/server/src/routes/aiModel.js +++ b/server/src/routes/aiModel.js @@ -22,7 +22,7 @@ router.get('/current', async (req, res) => { name: '默认模型', modelId: process.env.OPENAI_MODEL || 'gpt-3.5-turbo', apiUrl: process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions', - apiKey: process.env.OPENAI_API_KEY || '', + apiKey: '', // [安全] 不向客户端下发密钥 config: { temperature: 0.7, maxTokens: 800, diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 0f13d8b..320e02c 100755 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -333,7 +333,7 @@ router.post('/user/login', async (req, res) => { try { const { username, email, password } = req.body // 支持邮箱或用户名登录 - const identifier = email || username + const identifier = String(email || username || '') // [安全] 强转防对象注入 $ne // 查找用户(显式包含密码字段) const user = await User.findOne({ @@ -440,8 +440,9 @@ router.post('/user/login', async (req, res) => { */ router.post('/user/register', async (req, res) => { try { - const { username, email, password, nickName, avatarUrl, verifyCode } = req.body - const identifier = email || username + const { username, email, password, nickName, avatarUrl } = req.body + const verifyCode = String(req.body?.verifyCode || '') // [安全] 强转防注入 + const identifier = String(email || username || '') // [安全] 强转防对象注入 $ne if (!identifier) { return ApiResponse.error(res, '请输入邮箱或用户名', 400) diff --git a/server/src/routes/knowledge.js b/server/src/routes/knowledge.js index 38b8901..079dcd2 100755 --- a/server/src/routes/knowledge.js +++ b/server/src/routes/knowledge.js @@ -5,6 +5,7 @@ const { Knowledge, KnowledgeLit } = require('../models') const ApiResponse = require('../utils/response') const { userAuthMiddleware, optionalAuthMiddleware } = require('../middleware/auth') const logger = require('../utils/logger') +const { escapeRegExp } = require('../utils/escapeRegex') const isValidId = (id) => mongoose.Types.ObjectId.isValid(id) @@ -41,9 +42,9 @@ router.get('/search', async (req, res) => { const query = { status: 'approved', $or: [ - { title: { $regex: q, $options: 'i' } }, - { content: { $regex: q, $options: 'i' } }, - { tags: { $regex: q, $options: 'i' } } + { title: { $regex: escapeRegExp(q), $options: 'i' } }, + { content: { $regex: escapeRegExp(q), $options: 'i' } }, + { tags: { $regex: escapeRegExp(q), $options: 'i' } } ] } diff --git a/server/src/routes/user.js b/server/src/routes/user.js index 7ca5d27..8f4424b 100755 --- a/server/src/routes/user.js +++ b/server/src/routes/user.js @@ -11,13 +11,14 @@ const logger = require('../utils/logger') */ router.get('/leaderboard', async (req, res) => { try { - const { limit = 50, dim } = req.query + const limit = Math.min(100, parseInt(req.query.limit) || 50) // 封顶防拖库 + const dim = req.query.dim - const users = await User.getLeaderboard(dim ? parseInt(dim) : null, parseInt(limit)) + const users = await User.getLeaderboard(dim ? parseInt(dim) : null, limit) const data = users.map((u, i) => ({ rank: i + 1, - openid: u.openid, + // [安全] 不再下发 openid(配合已下线的 webview-token 防接管;昵称已足够展示) nickname: u.nickName || '星辰旅者', avatar: u.avatarUrl || '', totalScore: u.totalScore || 0, diff --git a/server/src/utils/escapeRegex.js b/server/src/utils/escapeRegex.js new file mode 100644 index 0000000..5d21473 --- /dev/null +++ b/server/src/utils/escapeRegex.js @@ -0,0 +1,5 @@ +/** 用户输入进 $regex 前转义特殊字符(防 ReDoS/正则注入) */ +function escapeRegExp(s) { + return String(s == null ? '' : s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} +module.exports = { escapeRegExp }