Files
nxxmdata/backend/middleware/security.js

300 lines
8.8 KiB
JavaScript
Raw Normal View History

2025-09-12 20:08:42 +08:00
/**
* 安全增强中间件
* @file security.js
* @description 实现登录失败限制会话超时输入验证等安全功能
*/
const rateLimit = require('express-rate-limit');
const logger = require('../utils/logger');
// 登录失败次数记录
const loginAttempts = new Map();
const MAX_LOGIN_ATTEMPTS = 5; // 最大登录尝试次数
const LOCKOUT_DURATION = 15 * 60 * 1000; // 锁定15分钟
/**
* 登录失败次数限制中间件
*/
const loginAttemptsLimiter = (req, res, next) => {
const clientIP = req.ip || req.connection.remoteAddress;
const identifier = req.body.username || clientIP; // 使用用户名或IP作为标识
const now = Date.now();
const attempts = loginAttempts.get(identifier) || { count: 0, firstAttempt: now, lockedUntil: 0 };
// 检查是否仍在锁定期内
if (attempts.lockedUntil > now) {
const remainingTime = Math.ceil((attempts.lockedUntil - now) / 1000 / 60); // 分钟
logger.warn(`用户 ${identifier} 尝试在锁定期内登录, IP: ${clientIP}`);
return res.status(429).json({
success: false,
message: `登录失败次数过多,请 ${remainingTime} 分钟后再试`,
lockedUntil: new Date(attempts.lockedUntil).toISOString()
});
}
// 重置过期的记录
if (now - attempts.firstAttempt > LOCKOUT_DURATION) {
attempts.count = 0;
attempts.firstAttempt = now;
}
// 检查失败次数
if (attempts.count >= MAX_LOGIN_ATTEMPTS) {
attempts.lockedUntil = now + LOCKOUT_DURATION;
loginAttempts.set(identifier, attempts);
logger.warn(`用户 ${identifier} 登录失败次数达到上限,已锁定, IP: ${clientIP}`);
return res.status(429).json({
success: false,
message: `登录失败次数过多已锁定15分钟`,
lockedUntil: new Date(attempts.lockedUntil).toISOString()
});
}
// 将attempts信息附加到请求对象供后续使用
req.loginAttempts = attempts;
req.loginIdentifier = identifier;
next();
};
/**
* 记录登录失败
*/
const recordLoginFailure = (req, res, next) => {
// 检查响应状态如果是401认证失败记录失败次数
const originalSend = res.send;
res.send = function(data) {
if (res.statusCode === 401 && req.loginIdentifier) {
const attempts = req.loginAttempts;
attempts.count++;
loginAttempts.set(req.loginIdentifier, attempts);
logger.warn(`用户 ${req.loginIdentifier} 登录失败,失败次数: ${attempts.count}/${MAX_LOGIN_ATTEMPTS}, IP: ${req.ip}`);
} else if (res.statusCode === 200 && req.loginIdentifier) {
// 登录成功,清除失败记录
loginAttempts.delete(req.loginIdentifier);
logger.info(`用户 ${req.loginIdentifier} 登录成功,已清除失败记录, IP: ${req.ip}`);
}
return originalSend.call(this, data);
};
next();
};
/**
* 清除过期的登录失败记录
*/
const cleanupExpiredAttempts = () => {
const now = Date.now();
const expiredKeys = [];
for (const [key, attempts] of loginAttempts.entries()) {
if (now - attempts.firstAttempt > LOCKOUT_DURATION * 2) { // 保留双倍锁定时间
expiredKeys.push(key);
}
}
expiredKeys.forEach(key => loginAttempts.delete(key));
if (expiredKeys.length > 0) {
logger.info(`清理了 ${expiredKeys.length} 个过期的登录失败记录`);
}
};
// 每小时清理一次过期记录
setInterval(cleanupExpiredAttempts, 60 * 60 * 1000);
/**
* API请求频率限制
*/
const apiRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分钟窗口
max: 1000, // 限制每个IP每15分钟最多1000个请求
message: {
success: false,
message: '请求过于频繁,请稍后再试'
},
standardHeaders: true,
legacyHeaders: false,
handler: (req, res) => {
logger.warn(`API请求频率超限, IP: ${req.ip}, URL: ${req.originalUrl}`);
res.status(429).json({
success: false,
message: '请求过于频繁,请稍后再试'
});
}
});
/**
* 登录请求频率限制
*/
const loginRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分钟窗口
max: 10, // 限制每个IP每15分钟最多10次登录尝试
message: {
success: false,
message: '登录请求过于频繁,请稍后再试'
},
skipSuccessfulRequests: true, // 成功请求不计入限制
handler: (req, res) => {
logger.warn(`登录请求频率超限, IP: ${req.ip}`);
res.status(429).json({
success: false,
message: '登录请求过于频繁请15分钟后再试'
});
}
});
/**
* 输入验证和XSS防护
*/
const inputSanitizer = (req, res, next) => {
// 递归清理对象中的危险字符
const sanitizeObject = (obj) => {
if (obj === null || obj === undefined) return obj;
if (typeof obj === 'string') {
// 移除潜在的XSS攻击字符
return obj
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '') // 移除script标签
.replace(/javascript:/gi, '') // 移除javascript协议
.replace(/on\w+\s*=/gi, '') // 移除事件处理器
.trim();
}
if (Array.isArray(obj)) {
return obj.map(sanitizeObject);
}
if (typeof obj === 'object') {
const sanitized = {};
for (const [key, value] of Object.entries(obj)) {
sanitized[key] = sanitizeObject(value);
}
return sanitized;
}
return obj;
};
// 清理请求体
if (req.body) {
req.body = sanitizeObject(req.body);
}
// 清理查询参数
if (req.query) {
req.query = sanitizeObject(req.query);
}
next();
};
/**
* 会话超时检查
*/
const sessionTimeoutCheck = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (token) {
try {
const jwt = require('jsonwebtoken');
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your_jwt_secret_key');
// 检查token是否即将过期剩余时间少于1小时
const now = Math.floor(Date.now() / 1000);
const timeUntilExpiry = decoded.exp - now;
if (timeUntilExpiry < 3600) { // 1小时 = 3600秒
res.set('X-Token-Expiry-Warning', 'true');
res.set('X-Token-Expires-In', timeUntilExpiry.toString());
}
} catch (error) {
// Token无效或已过期不做特殊处理让后续中间件处理
}
}
next();
};
/**
* 安全响应头设置
*/
const securityHeaders = (req, res, next) => {
// 防止点击劫持
res.set('X-Frame-Options', 'DENY');
// 防止MIME类型嗅探
res.set('X-Content-Type-Options', 'nosniff');
// XSS保护
res.set('X-XSS-Protection', '1; mode=block');
// 引用者策略
res.set('Referrer-Policy', 'strict-origin-when-cross-origin');
// 内容安全策略 - 允许百度地图API
const cspPolicy = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' api.map.baidu.com apimaponline0.bdimg.com apimaponline1.bdimg.com apimaponline2.bdimg.com apimaponline3.bdimg.com dlswbr.baidu.com miao.baidu.com",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: api.map.baidu.com apimaponline0.bdimg.com apimaponline1.bdimg.com apimaponline2.bdimg.com apimaponline3.bdimg.com dlswbr.baidu.com miao.baidu.com",
"connect-src 'self' api.map.baidu.com apimaponline0.bdimg.com apimaponline1.bdimg.com apimaponline2.bdimg.com apimaponline3.bdimg.com dlswbr.baidu.com miao.baidu.com",
"frame-src 'self'"
].join('; ');
res.set('Content-Security-Policy', cspPolicy);
next();
};
/**
* 获取登录失败统计信息
* @returns {Object} 统计信息
*/
const getLoginAttemptStats = () => {
const now = Date.now();
let totalAttempts = 0;
let lockedAccounts = 0;
let recentFailures = 0;
for (const [identifier, attempts] of loginAttempts.entries()) {
totalAttempts += attempts.count;
if (attempts.lockedUntil > now) {
lockedAccounts++;
}
if (now - attempts.firstAttempt < 60 * 60 * 1000) { // 最近1小时
recentFailures += attempts.count;
}
}
return {
totalTrackedIdentifiers: loginAttempts.size,
totalFailedAttempts: totalAttempts,
currentlyLockedAccounts: lockedAccounts,
recentHourFailures: recentFailures,
maxAttemptsAllowed: MAX_LOGIN_ATTEMPTS,
lockoutDurationMinutes: LOCKOUT_DURATION / 60 / 1000
};
};
module.exports = {
loginAttemptsLimiter,
recordLoginFailure,
apiRateLimiter,
loginRateLimiter,
inputSanitizer,
sessionTimeoutCheck,
securityHeaders,
getLoginAttemptStats,
cleanupExpiredAttempts
};