fix security and import validation
This commit is contained in:
@@ -7,6 +7,43 @@ const logger = require('../utils/logger');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const loginAttempts = new Map();
|
||||
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
||||
const LOGIN_MAX_FAILURES = 5;
|
||||
|
||||
function normalizeLoginKey(req, username) {
|
||||
return `${req.ip || req.socket.remoteAddress || 'unknown'}:${String(username || '').trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
function getLoginAttempt(key) {
|
||||
const now = Date.now();
|
||||
const attempt = loginAttempts.get(key);
|
||||
if (!attempt || attempt.resetAt <= now) {
|
||||
const fresh = { failures: 0, resetAt: now + LOGIN_WINDOW_MS };
|
||||
loginAttempts.set(key, fresh);
|
||||
return fresh;
|
||||
}
|
||||
return attempt;
|
||||
}
|
||||
|
||||
function clearExpiredLoginAttempts() {
|
||||
const now = Date.now();
|
||||
for (const [key, attempt] of loginAttempts) {
|
||||
if (attempt.resetAt <= now) loginAttempts.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function recordFailedLogin(key) {
|
||||
const attempt = getLoginAttempt(key);
|
||||
attempt.failures += 1;
|
||||
if (loginAttempts.size > 1000) clearExpiredLoginAttempts();
|
||||
return attempt;
|
||||
}
|
||||
|
||||
function resetLoginAttempts(key) {
|
||||
loginAttempts.delete(key);
|
||||
}
|
||||
|
||||
// JWT 中间件
|
||||
function auth(req, res, next) {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
@@ -27,15 +64,58 @@ router.post('/login', (req, res) => {
|
||||
return res.status(400).json({ error: '用户名和密码不能为空' });
|
||||
}
|
||||
|
||||
const loginKey = normalizeLoginKey(req, username);
|
||||
const attempt = getLoginAttempt(loginKey);
|
||||
if (attempt.failures >= LOGIN_MAX_FAILURES) {
|
||||
const retryAfter = Math.ceil((attempt.resetAt - Date.now()) / 1000);
|
||||
res.setHeader('Retry-After', String(Math.max(retryAfter, 1)));
|
||||
return res.status(429).json({ error: '登录失败次数过多,请稍后再试' });
|
||||
}
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||
if (!user || !bcrypt.compareSync(password, user.password)) {
|
||||
recordFailedLogin(loginKey);
|
||||
logger.warn(`登录失败: ${username}`);
|
||||
return res.status(401).json({ error: '用户名或密码错误' });
|
||||
}
|
||||
|
||||
resetLoginAttempts(loginKey);
|
||||
const token = jwt.sign({ id: user.id, username: user.username }, config.jwtSecret, { expiresIn: config.jwtExpiresIn });
|
||||
logger.info(`用户登录: ${username}`);
|
||||
res.json({ token, username: user.username });
|
||||
});
|
||||
|
||||
// 注册
|
||||
router.post('/register', (req, res) => {
|
||||
if (!config.enableRegistration) {
|
||||
return res.status(403).json({ error: '注册已关闭' });
|
||||
}
|
||||
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: '用户名和密码不能为空' });
|
||||
}
|
||||
|
||||
if (username.length < 2 || username.length > 20) {
|
||||
return res.status(400).json({ error: '用户名长度需在2-20个字符之间' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: '密码长度不能少于6个字符' });
|
||||
}
|
||||
|
||||
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: '用户名已存在' });
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
const result = db.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run(username, hash);
|
||||
logger.info(`新用户注册: ${username}`);
|
||||
|
||||
const token = jwt.sign({ id: result.lastInsertRowid, username }, config.jwtSecret, { expiresIn: config.jwtExpiresIn });
|
||||
res.json({ token, username });
|
||||
});
|
||||
|
||||
module.exports = { router, auth };
|
||||
|
||||
+90
-25
@@ -1,10 +1,26 @@
|
||||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const config = require('../config');
|
||||
const logger = require('../utils/logger');
|
||||
const { parseCSVLine, AppError } = require('../utils/helpers');
|
||||
const {
|
||||
parseCSVLine,
|
||||
AppError,
|
||||
validateNumber,
|
||||
validateInList,
|
||||
validateIsoDate,
|
||||
optionalString
|
||||
} = require('../utils/helpers');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function emptyToNull(value) {
|
||||
return value === undefined || value === null || value === '' ? null : value;
|
||||
}
|
||||
|
||||
function nullableDbValue(value) {
|
||||
return value === undefined || value === null || value === '' ? null : value;
|
||||
}
|
||||
|
||||
// 导出数据
|
||||
router.get('/export', (req, res, next) => {
|
||||
try {
|
||||
@@ -89,29 +105,63 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res,
|
||||
if (!lines[i].trim()) continue;
|
||||
const values = parseCSVLine(lines[i]);
|
||||
const record = {};
|
||||
csvHeaders.forEach((h, idx) => { record[h] = values[idx] || null; });
|
||||
if (record.amount) record.amount = parseFloat(record.amount);
|
||||
if (record.quantity) record.quantity = parseInt(record.quantity) || null;
|
||||
if (record.unit_price) record.unit_price = parseFloat(record.unit_price) || null;
|
||||
if (record.rebate) record.rebate = parseFloat(record.rebate) || null;
|
||||
csvHeaders.forEach((h, idx) => { record[h] = emptyToNull(values[idx]); });
|
||||
if (record.amount !== null) record.amount = parseFloat(record.amount);
|
||||
if (record.quantity !== null) record.quantity = parseInt(record.quantity, 10);
|
||||
if (record.unit_price !== null) record.unit_price = parseFloat(record.unit_price);
|
||||
if (record.rebate !== null) record.rebate = parseFloat(record.rebate);
|
||||
records.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
for (const r of records) {
|
||||
for (const f of requiredFields) {
|
||||
if (!r[f] && r[f] !== 0) {
|
||||
throw new AppError(`记录缺少必填字段: ${f}`, 400);
|
||||
}
|
||||
}
|
||||
if (!['income', 'expense'].includes(r.type)) {
|
||||
throw new AppError(`无效的类型: ${r.type}`, 400);
|
||||
}
|
||||
if (records.length > config.import.maxRecords) {
|
||||
throw new AppError(`单次最多导入 ${config.import.maxRecords} 条记录`, 400);
|
||||
}
|
||||
|
||||
const existingCheck = db.prepare(
|
||||
'SELECT id FROM records WHERE user_id = ? AND created_at = ? AND category = ? AND amount = ?'
|
||||
);
|
||||
const allCategories = [...config.categories.income, ...config.categories.expense];
|
||||
const normalizedRecords = records.map((r, index) => {
|
||||
for (const f of requiredFields) {
|
||||
if (!r[f] && r[f] !== 0) {
|
||||
throw new AppError(`第 ${index + 1} 条记录缺少必填字段: ${f}`, 400);
|
||||
}
|
||||
}
|
||||
validateInList(r.type, 'type', ['income', 'expense']);
|
||||
validateInList(r.category, 'category', allCategories);
|
||||
const amount = r.category === '派单'
|
||||
? validateNumber(r.amount, '金额')
|
||||
: validateNumber(r.amount, '金额', 0);
|
||||
return {
|
||||
type: r.type,
|
||||
category: r.category,
|
||||
amount,
|
||||
quantity: r.quantity === undefined || r.quantity === null || r.quantity === '' ? null : validateNumber(r.quantity, '数量', 0),
|
||||
unit_price: r.unit_price === undefined || r.unit_price === null || r.unit_price === '' ? null : validateNumber(r.unit_price, '单价', 0),
|
||||
rebate: r.rebate === undefined || r.rebate === null || r.rebate === '' ? null : validateNumber(r.rebate, '返点'),
|
||||
boss: optionalString(r.boss, '老板', 100),
|
||||
partner: optionalString(r.partner, '陪玩', 100),
|
||||
source: optionalString(r.source, '来源', 100),
|
||||
destination: optionalString(r.destination, '去向', 100),
|
||||
note: optionalString(r.note, '备注', 500),
|
||||
created_at: validateIsoDate(r.created_at, '创建时间')
|
||||
};
|
||||
});
|
||||
|
||||
const existingCheck = db.prepare(`
|
||||
SELECT id FROM records
|
||||
WHERE user_id = ?
|
||||
AND type = ?
|
||||
AND category = ?
|
||||
AND amount = ?
|
||||
AND COALESCE(quantity, '') = COALESCE(?, '')
|
||||
AND COALESCE(unit_price, '') = COALESCE(?, '')
|
||||
AND COALESCE(rebate, '') = COALESCE(?, '')
|
||||
AND COALESCE(boss, '') = COALESCE(?, '')
|
||||
AND COALESCE(partner, '') = COALESCE(?, '')
|
||||
AND COALESCE(source, '') = COALESCE(?, '')
|
||||
AND COALESCE(destination, '') = COALESCE(?, '')
|
||||
AND COALESCE(note, '') = COALESCE(?, '')
|
||||
AND created_at = ?
|
||||
`);
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -120,21 +170,36 @@ router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res,
|
||||
let imported = 0, skipped = 0;
|
||||
const insertMany = db.transaction((recs) => {
|
||||
for (const r of recs) {
|
||||
if (existingCheck.get(req.user.id, r.created_at, r.category, r.amount)) {
|
||||
const values = [
|
||||
req.user.id,
|
||||
r.type,
|
||||
r.category,
|
||||
r.amount,
|
||||
r.quantity,
|
||||
r.unit_price,
|
||||
r.rebate,
|
||||
r.boss,
|
||||
r.partner,
|
||||
r.source,
|
||||
r.destination,
|
||||
r.note,
|
||||
r.created_at
|
||||
];
|
||||
if (existingCheck.get(...values)) {
|
||||
skipped++; continue;
|
||||
}
|
||||
insertStmt.run(req.user.id, r.type, r.category, r.amount,
|
||||
r.quantity || null, r.unit_price || null, r.rebate || null,
|
||||
r.boss || null, r.partner || null,
|
||||
r.source || null, r.destination || null,
|
||||
r.note || null, r.created_at);
|
||||
nullableDbValue(r.quantity), nullableDbValue(r.unit_price), nullableDbValue(r.rebate),
|
||||
nullableDbValue(r.boss), nullableDbValue(r.partner),
|
||||
nullableDbValue(r.source), nullableDbValue(r.destination),
|
||||
nullableDbValue(r.note), r.created_at);
|
||||
imported++;
|
||||
}
|
||||
});
|
||||
insertMany(records);
|
||||
insertMany(normalizedRecords);
|
||||
|
||||
logger.info(`用户 ${req.user.username} 导入数据: ${imported} 条, 跳过 ${skipped} 条`);
|
||||
res.json({ imported, skipped, total: records.length });
|
||||
res.json({ imported, skipped, total: normalizedRecords.length });
|
||||
} catch (err) {
|
||||
if (err instanceof SyntaxError) {
|
||||
next(new AppError('文件解析失败: ' + err.message, 400));
|
||||
|
||||
+70
-11
@@ -2,7 +2,17 @@ const express = require('express');
|
||||
const db = require('../db');
|
||||
const config = require('../config');
|
||||
const logger = require('../utils/logger');
|
||||
const { getMonthRange, AppError, validateRequired, validateNumber, validateInList } = require('../utils/helpers');
|
||||
const {
|
||||
getMonthRange,
|
||||
AppError,
|
||||
validateRequired,
|
||||
validateNumber,
|
||||
validateInList,
|
||||
validatePage,
|
||||
validateLimit,
|
||||
validateIsoDate,
|
||||
optionalString
|
||||
} = require('../utils/helpers');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -18,13 +28,34 @@ router.post('/', (req, res, next) => {
|
||||
const allCategories = [...config.categories.income, ...config.categories.expense];
|
||||
validateInList(category, 'category', allCategories);
|
||||
|
||||
const validatedAmount = validateNumber(amount, '金额', 0);
|
||||
// 派单的返点可为负数,因此金额也允许为负数
|
||||
const validatedAmount = category === '派单'
|
||||
? validateNumber(amount, '金额')
|
||||
: validateNumber(amount, '金额', 0);
|
||||
const validatedQuantity = quantity === undefined || quantity === null || quantity === '' ? null : validateNumber(quantity, '数量', 0);
|
||||
const validatedUnitPrice = unit_price === undefined || unit_price === null || unit_price === '' ? null : validateNumber(unit_price, '单价', 0);
|
||||
const validatedRebate = rebate === undefined || rebate === null || rebate === '' ? null : validateNumber(rebate, '返点');
|
||||
const validatedCreatedAt = validateIsoDate(created_at, '创建时间');
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const result = stmt.run(req.user.id, type, category, validatedAmount, quantity || null, unit_price || null, rebate || null, boss || null, partner || null, source || null, destination || null, note || null, created_at || new Date().toISOString());
|
||||
const result = stmt.run(
|
||||
req.user.id,
|
||||
type,
|
||||
category,
|
||||
validatedAmount,
|
||||
validatedQuantity,
|
||||
validatedUnitPrice,
|
||||
validatedRebate,
|
||||
optionalString(boss, '老板', 100),
|
||||
optionalString(partner, '陪玩', 100),
|
||||
optionalString(source, '来源', 100),
|
||||
optionalString(destination, '去向', 100),
|
||||
optionalString(note, '备注', 500),
|
||||
validatedCreatedAt
|
||||
);
|
||||
|
||||
logger.info(`用户 ${req.user.username} 新增记录: ${category} ${validatedAmount}`);
|
||||
res.json({ id: result.lastInsertRowid });
|
||||
@@ -35,8 +66,9 @@ router.post('/', (req, res, next) => {
|
||||
|
||||
// 通用分页查询函数
|
||||
function queryPagedRecords(userId, year, month, options) {
|
||||
const { page = 1, limit = 20 } = options;
|
||||
const offset = (Number(page) - 1) * Number(limit);
|
||||
const page = validatePage(options.page, 1);
|
||||
const limit = validateLimit(options.limit, config.pagination.defaultLimit, config.pagination.maxLimit);
|
||||
const offset = (page - 1) * limit;
|
||||
const { startDate, endDate } = getMonthRange(year, month);
|
||||
|
||||
let whereClause = `user_id = ? AND DATE(created_at, 'localtime') >= ? AND DATE(created_at, 'localtime') < ?`;
|
||||
@@ -61,9 +93,9 @@ function queryPagedRecords(userId, year, month, options) {
|
||||
|
||||
const records = db.prepare(
|
||||
`SELECT * FROM records WHERE ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
||||
).all(...params, Number(limit), offset);
|
||||
).all(...params, limit, offset);
|
||||
|
||||
return { records, total, page: Number(page), limit: Number(limit) };
|
||||
return { records, total, page, limit };
|
||||
}
|
||||
|
||||
// 接单列表
|
||||
@@ -122,7 +154,9 @@ router.get('/dispatches', (req, res) => {
|
||||
// 获取记录列表
|
||||
router.get('/', (req, res) => {
|
||||
const { type, page = 1, limit = 20 } = req.query;
|
||||
const offset = (page - 1) * limit;
|
||||
const validatedPage = validatePage(page, 1);
|
||||
const validatedLimit = validateLimit(limit, config.pagination.defaultLimit, config.pagination.maxLimit);
|
||||
const offset = (validatedPage - 1) * validatedLimit;
|
||||
let sql = 'SELECT * FROM records WHERE user_id = ?';
|
||||
const params = [req.user.id];
|
||||
if (type) {
|
||||
@@ -130,7 +164,7 @@ router.get('/', (req, res) => {
|
||||
params.push(type);
|
||||
}
|
||||
sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
|
||||
params.push(Number(limit), Number(offset));
|
||||
params.push(validatedLimit, offset);
|
||||
res.json(db.prepare(sql).all(...params));
|
||||
});
|
||||
|
||||
@@ -144,12 +178,37 @@ router.put('/:id', (req, res, next) => {
|
||||
throw new AppError('无效的记录ID', 400);
|
||||
}
|
||||
|
||||
const validatedAmount = validateNumber(amount, '金额', 0);
|
||||
// 获取原有记录以判断类别(派单允许负数金额)
|
||||
const existing = db.prepare('SELECT category FROM records WHERE id = ? AND user_id = ?').get(id, req.user.id);
|
||||
if (!existing) {
|
||||
throw new AppError('记录不存在', 404);
|
||||
}
|
||||
|
||||
const validatedAmount = existing.category === '派单'
|
||||
? validateNumber(amount, '金额')
|
||||
: validateNumber(amount, '金额', 0);
|
||||
const validatedQuantity = quantity === undefined || quantity === null || quantity === '' ? null : validateNumber(quantity, '数量', 0);
|
||||
const validatedUnitPrice = unit_price === undefined || unit_price === null || unit_price === '' ? null : validateNumber(unit_price, '单价', 0);
|
||||
const validatedRebate = rebate === undefined || rebate === null || rebate === '' ? null : validateNumber(rebate, '返点');
|
||||
const validatedCreatedAt = validateIsoDate(created_at, '创建时间');
|
||||
|
||||
db.prepare(`
|
||||
UPDATE records SET quantity = ?, unit_price = ?, rebate = ?, amount = ?, boss = ?, partner = ?, source = ?, destination = ?, note = ?, created_at = ?
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(quantity || null, unit_price || null, rebate || null, validatedAmount, boss || null, partner || null, source || null, destination || null, note || null, created_at || null, id, req.user.id);
|
||||
`).run(
|
||||
validatedQuantity,
|
||||
validatedUnitPrice,
|
||||
validatedRebate,
|
||||
validatedAmount,
|
||||
optionalString(boss, '老板', 100),
|
||||
optionalString(partner, '陪玩', 100),
|
||||
optionalString(source, '来源', 100),
|
||||
optionalString(destination, '去向', 100),
|
||||
optionalString(note, '备注', 500),
|
||||
validatedCreatedAt,
|
||||
id,
|
||||
req.user.id
|
||||
);
|
||||
|
||||
logger.debug(`用户 ${req.user.username} 更新记录: ${id}`);
|
||||
res.json({ success: true });
|
||||
|
||||
Reference in New Issue
Block a user