Files
nxxmdata/backend/middleware/security.js
2025-09-12 20:08:42 +08:00

300 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 安全增强中间件
* @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
};