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