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