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