Fix jwt-token
This commit is contained in:
124
server/migrations/20250924-create-security-logs.js
Normal file
124
server/migrations/20250924-create-security-logs.js
Normal file
@@ -0,0 +1,124 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.createTable('security_logs', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: Sequelize.UUIDV4,
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'tenants',
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'SET NULL'
|
||||
},
|
||||
event_type: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false
|
||||
},
|
||||
severity: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'info'
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true
|
||||
},
|
||||
username: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true
|
||||
},
|
||||
ip_address: {
|
||||
type: DataTypes.INET,
|
||||
allowNull: true
|
||||
},
|
||||
client_ip: {
|
||||
type: DataTypes.INET,
|
||||
allowNull: true
|
||||
},
|
||||
user_agent: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
rdns: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true
|
||||
},
|
||||
country_code: {
|
||||
type: DataTypes.STRING(2),
|
||||
allowNull: true
|
||||
},
|
||||
country_name: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true
|
||||
},
|
||||
city: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true
|
||||
},
|
||||
is_high_risk_country: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
},
|
||||
message: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
metadata: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
defaultValue: {}
|
||||
},
|
||||
session_id: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true
|
||||
},
|
||||
request_id: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true
|
||||
},
|
||||
endpoint: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true
|
||||
},
|
||||
method: {
|
||||
type: DataTypes.STRING(10),
|
||||
allowNull: true
|
||||
},
|
||||
status_code: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
alerted: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: Sequelize.NOW,
|
||||
allowNull: false
|
||||
}
|
||||
});
|
||||
|
||||
// Add indexes for performance
|
||||
await queryInterface.addIndex('security_logs', ['tenant_id', 'created_at']);
|
||||
await queryInterface.addIndex('security_logs', ['event_type', 'created_at']);
|
||||
await queryInterface.addIndex('security_logs', ['ip_address', 'created_at']);
|
||||
await queryInterface.addIndex('security_logs', ['username', 'created_at']);
|
||||
await queryInterface.addIndex('security_logs', ['severity', 'created_at']);
|
||||
await queryInterface.addIndex('security_logs', ['country_code', 'is_high_risk_country']);
|
||||
await queryInterface.addIndex('security_logs', ['alerted', 'severity', 'created_at']);
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.dropTable('security_logs');
|
||||
}
|
||||
};
|
||||
160
server/models/SecurityLog.js
Normal file
160
server/models/SecurityLog.js
Normal file
@@ -0,0 +1,160 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const SecurityLog = sequelize.define('SecurityLog', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: sequelize.Sequelize.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'tenants',
|
||||
key: 'id'
|
||||
},
|
||||
comment: 'Tenant ID for multi-tenant isolation (null for system-wide events)'
|
||||
},
|
||||
event_type: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
comment: 'Type of security event (login_failed, login_success, suspicious_pattern, etc.)'
|
||||
},
|
||||
severity: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'info',
|
||||
validate: {
|
||||
isIn: [['low', 'medium', 'high', 'critical']]
|
||||
},
|
||||
comment: 'Severity level of the security event'
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
comment: 'User ID if applicable'
|
||||
},
|
||||
username: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
comment: 'Username involved in the event'
|
||||
},
|
||||
ip_address: {
|
||||
type: DataTypes.INET,
|
||||
allowNull: true,
|
||||
comment: 'Client IP address'
|
||||
},
|
||||
client_ip: {
|
||||
type: DataTypes.INET,
|
||||
allowNull: true,
|
||||
comment: 'Real client IP (if behind proxy/load balancer)'
|
||||
},
|
||||
user_agent: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'User agent string'
|
||||
},
|
||||
rdns: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
comment: 'Reverse DNS lookup of IP address'
|
||||
},
|
||||
country_code: {
|
||||
type: DataTypes.STRING(2),
|
||||
allowNull: true,
|
||||
comment: 'ISO country code from IP geolocation'
|
||||
},
|
||||
country_name: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
comment: 'Country name from IP geolocation'
|
||||
},
|
||||
city: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
comment: 'City from IP geolocation'
|
||||
},
|
||||
is_high_risk_country: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
comment: 'Whether the country is flagged as high-risk'
|
||||
},
|
||||
message: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false,
|
||||
comment: 'Detailed description of the security event'
|
||||
},
|
||||
metadata: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
defaultValue: {},
|
||||
comment: 'Additional event-specific data'
|
||||
},
|
||||
session_id: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
comment: 'Session ID if applicable'
|
||||
},
|
||||
request_id: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
comment: 'Request ID for correlation'
|
||||
},
|
||||
endpoint: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
comment: 'API endpoint or URL involved'
|
||||
},
|
||||
method: {
|
||||
type: DataTypes.STRING(10),
|
||||
allowNull: true,
|
||||
comment: 'HTTP method'
|
||||
},
|
||||
status_code: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
comment: 'HTTP response status code'
|
||||
},
|
||||
alerted: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
comment: 'Whether super admins have been alerted about this event'
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: sequelize.Sequelize.NOW,
|
||||
comment: 'When the event occurred'
|
||||
}
|
||||
}, {
|
||||
tableName: 'security_logs',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: false, // Security logs should not be updated
|
||||
indexes: [
|
||||
{
|
||||
fields: ['tenant_id', 'created_at']
|
||||
},
|
||||
{
|
||||
fields: ['event_type', 'created_at']
|
||||
},
|
||||
{
|
||||
fields: ['ip_address', 'created_at']
|
||||
},
|
||||
{
|
||||
fields: ['username', 'created_at']
|
||||
},
|
||||
{
|
||||
fields: ['severity', 'created_at']
|
||||
},
|
||||
{
|
||||
fields: ['country_code', 'is_high_risk_country']
|
||||
},
|
||||
{
|
||||
fields: ['alerted', 'severity', 'created_at']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return SecurityLog;
|
||||
};
|
||||
@@ -391,9 +391,14 @@ router.get('/', authenticateToken, requireRole(['admin']), async (req, res) => {
|
||||
* Called by multi-tenant auth middleware
|
||||
*/
|
||||
async function loginLocal(req, res, next) {
|
||||
const securityLogger = require('../services/securityLogService');
|
||||
|
||||
try {
|
||||
// Validate required fields
|
||||
if (!req.body.username || !req.body.password) {
|
||||
// Log validation failure
|
||||
await securityLogger.logFailedLogin(req, req.body.username || 'unknown', 'Missing credentials');
|
||||
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validation error for field' +
|
||||
@@ -444,6 +449,10 @@ async function loginLocal(req, res, next) {
|
||||
|
||||
if (!user) {
|
||||
console.log(`❌ Authentication failed for "${username}" in tenant "${req.tenant?.id}" - User not found`);
|
||||
|
||||
// Log failed login attempt
|
||||
await securityLogger.logFailedLogin(req, username, 'User not found');
|
||||
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Invalid credentials'
|
||||
@@ -452,6 +461,10 @@ async function loginLocal(req, res, next) {
|
||||
|
||||
if (!user.is_active) {
|
||||
console.log(`❌ Authentication failed for "${username}" in tenant "${req.tenant?.id}" - Account is inactive`);
|
||||
|
||||
// Log failed login attempt
|
||||
await securityLogger.logFailedLogin(req, username, 'Account is inactive');
|
||||
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Account is inactive'
|
||||
@@ -462,6 +475,10 @@ async function loginLocal(req, res, next) {
|
||||
|
||||
if (!passwordMatch) {
|
||||
console.log(`❌ Authentication failed for "${username}" in tenant "${req.tenant?.id}" - Invalid password`);
|
||||
|
||||
// Log failed login attempt
|
||||
await securityLogger.logFailedLogin(req, username, 'Invalid password');
|
||||
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Invalid credentials'
|
||||
@@ -470,6 +487,13 @@ async function loginLocal(req, res, next) {
|
||||
|
||||
console.log(`✅ Authentication successful for "${username}" in tenant "${req.tenant?.id}"`);
|
||||
|
||||
// Check if this IP had recent failed attempts
|
||||
const clientIP = securityLogger.getRealClientIP(req) || securityLogger.getClientIP(req);
|
||||
const hadRecentFailures = securityLogger.hadRecentFailedAttempts(clientIP);
|
||||
|
||||
// Log successful login
|
||||
await securityLogger.logSuccessfulLogin(req, user, hadRecentFailures);
|
||||
|
||||
// Update last login
|
||||
await user.update({ last_login: new Date() });
|
||||
|
||||
|
||||
420
server/services/securityLogService.js
Normal file
420
server/services/securityLogService.js
Normal file
@@ -0,0 +1,420 @@
|
||||
const dns = require('dns');
|
||||
const { promisify } = require('util');
|
||||
const reverseLookup = promisify(dns.reverse);
|
||||
|
||||
class SecurityLogService {
|
||||
constructor() {
|
||||
// High-risk countries (ISO 2-letter codes)
|
||||
this.HIGH_RISK_COUNTRIES = new Set([
|
||||
'RU', // Russia
|
||||
'CN', // China
|
||||
'KP', // North Korea
|
||||
'IR', // Iran
|
||||
'BY', // Belarus
|
||||
// Add more as needed
|
||||
]);
|
||||
|
||||
// Cache for IP lookups to avoid repeated API calls
|
||||
this.ipCache = new Map();
|
||||
this.rdnsCache = new Map();
|
||||
|
||||
// Suspicious activity tracking
|
||||
this.recentFailedLogins = new Map(); // IP -> array of timestamps
|
||||
this.recentSuccessfulLogins = new Map(); // IP -> array of timestamps
|
||||
|
||||
// Clean up old tracking data every hour
|
||||
setInterval(() => this.cleanupTrackingData(), 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a security event
|
||||
*/
|
||||
async logSecurityEvent(eventData) {
|
||||
try {
|
||||
const SecurityLog = require('../models/SecurityLog');
|
||||
|
||||
// Enhance event data with IP information
|
||||
const enhancedData = await this.enhanceEventData(eventData);
|
||||
|
||||
// Create the security log entry
|
||||
const securityLog = await SecurityLog.create(enhancedData);
|
||||
|
||||
// Check for suspicious patterns if this is a login event
|
||||
if (eventData.event_type.includes('login')) {
|
||||
await this.checkSuspiciousPatterns(enhancedData);
|
||||
}
|
||||
|
||||
console.log(`🔐 Security event logged: ${eventData.event_type} from ${eventData.ip_address}`);
|
||||
|
||||
return securityLog;
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to log security event:', error.message);
|
||||
// Don't throw - security logging failures shouldn't break the main application
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log failed login attempt
|
||||
*/
|
||||
async logFailedLogin(req, username, reason = 'Invalid credentials') {
|
||||
const eventData = {
|
||||
event_type: 'login_failed',
|
||||
severity: 'medium',
|
||||
username: username,
|
||||
ip_address: this.getClientIP(req),
|
||||
client_ip: this.getRealClientIP(req),
|
||||
user_agent: req.get('User-Agent'),
|
||||
endpoint: req.originalUrl || req.url,
|
||||
method: req.method,
|
||||
status_code: 401,
|
||||
message: `Failed login attempt for user '${username}': ${reason}`,
|
||||
metadata: {
|
||||
reason: reason,
|
||||
referer: req.get('Referer'),
|
||||
origin: req.get('Origin'),
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
session_id: req.sessionID,
|
||||
request_id: req.id || req.get('X-Request-ID')
|
||||
};
|
||||
|
||||
// Track failed login for pattern detection
|
||||
const clientIP = eventData.client_ip || eventData.ip_address;
|
||||
this.trackFailedLogin(clientIP, username);
|
||||
|
||||
return await this.logSecurityEvent(eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log successful login
|
||||
*/
|
||||
async logSuccessfulLogin(req, user, wasAfterFailedAttempt = false) {
|
||||
const severity = wasAfterFailedAttempt ? 'high' : 'low';
|
||||
|
||||
const eventData = {
|
||||
event_type: 'login_success',
|
||||
severity: severity,
|
||||
user_id: user.id,
|
||||
username: user.username || user.email,
|
||||
ip_address: this.getClientIP(req),
|
||||
client_ip: this.getRealClientIP(req),
|
||||
user_agent: req.get('User-Agent'),
|
||||
endpoint: req.originalUrl || req.url,
|
||||
method: req.method,
|
||||
status_code: 200,
|
||||
message: `Successful login for user '${user.username || user.email}'${wasAfterFailedAttempt ? ' (after failed attempts)' : ''}`,
|
||||
metadata: {
|
||||
user_id: user.id,
|
||||
after_failed_attempt: wasAfterFailedAttempt,
|
||||
referer: req.get('Referer'),
|
||||
origin: req.get('Origin'),
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
session_id: req.sessionID,
|
||||
request_id: req.id || req.get('X-Request-ID')
|
||||
};
|
||||
|
||||
// Track successful login for pattern detection
|
||||
const clientIP = eventData.client_ip || eventData.ip_address;
|
||||
this.trackSuccessfulLogin(clientIP);
|
||||
|
||||
return await this.logSecurityEvent(eventData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhance event data with IP geolocation and reverse DNS
|
||||
*/
|
||||
async enhanceEventData(eventData) {
|
||||
const clientIP = eventData.client_ip || eventData.ip_address;
|
||||
|
||||
if (clientIP) {
|
||||
// Get geolocation data
|
||||
const geoData = await this.getIPGeolocation(clientIP);
|
||||
if (geoData) {
|
||||
eventData.country_code = geoData.country_code;
|
||||
eventData.country_name = geoData.country_name;
|
||||
eventData.city = geoData.city;
|
||||
eventData.is_high_risk_country = this.HIGH_RISK_COUNTRIES.has(geoData.country_code);
|
||||
}
|
||||
|
||||
// Get reverse DNS
|
||||
eventData.rdns = await this.getRDNS(clientIP);
|
||||
|
||||
// Enhance logging for high-risk countries
|
||||
if (eventData.is_high_risk_country) {
|
||||
eventData.severity = this.escalateSeverity(eventData.severity);
|
||||
eventData.message += ` [HIGH-RISK COUNTRY: ${eventData.country_name}]`;
|
||||
}
|
||||
}
|
||||
|
||||
return eventData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP address, handling proxies
|
||||
*/
|
||||
getClientIP(req) {
|
||||
return req.ip ||
|
||||
req.connection?.remoteAddress ||
|
||||
req.socket?.remoteAddress ||
|
||||
req.connection?.socket?.remoteAddress ||
|
||||
'127.0.0.1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get real client IP from headers (X-Forwarded-For, X-Real-IP, etc.)
|
||||
*/
|
||||
getRealClientIP(req) {
|
||||
const xForwardedFor = req.get('X-Forwarded-For');
|
||||
if (xForwardedFor) {
|
||||
return xForwardedFor.split(',')[0].trim();
|
||||
}
|
||||
|
||||
return req.get('X-Real-IP') ||
|
||||
req.get('X-Client-IP') ||
|
||||
req.get('CF-Connecting-IP') || // Cloudflare
|
||||
this.getClientIP(req);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get IP geolocation (stub - implement with actual service)
|
||||
*/
|
||||
async getIPGeolocation(ip) {
|
||||
// Skip localhost and private IPs
|
||||
if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.') || ip.startsWith('172.')) {
|
||||
return {
|
||||
country_code: 'XX',
|
||||
country_name: 'Local/Private',
|
||||
city: 'Local'
|
||||
};
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
if (this.ipCache.has(ip)) {
|
||||
return this.ipCache.get(ip);
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO: Integrate with actual IP geolocation service (MaxMind, ipapi, etc.)
|
||||
// For now, return a placeholder
|
||||
const geoData = {
|
||||
country_code: 'US',
|
||||
country_name: 'United States',
|
||||
city: 'Unknown'
|
||||
};
|
||||
|
||||
// Cache for 24 hours
|
||||
this.ipCache.set(ip, geoData);
|
||||
setTimeout(() => this.ipCache.delete(ip), 24 * 60 * 60 * 1000);
|
||||
|
||||
return geoData;
|
||||
} catch (error) {
|
||||
console.error(`Failed to get geolocation for IP ${ip}:`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reverse DNS for IP
|
||||
*/
|
||||
async getRDNS(ip) {
|
||||
// Skip localhost and private IPs
|
||||
if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.') || ip.startsWith('172.')) {
|
||||
return 'localhost';
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
if (this.rdnsCache.has(ip)) {
|
||||
return this.rdnsCache.get(ip);
|
||||
}
|
||||
|
||||
try {
|
||||
const hostnames = await reverseLookup(ip);
|
||||
const rdns = hostnames[0] || null;
|
||||
|
||||
// Cache for 1 hour
|
||||
this.rdnsCache.set(ip, rdns);
|
||||
setTimeout(() => this.rdnsCache.delete(ip), 60 * 60 * 1000);
|
||||
|
||||
return rdns;
|
||||
} catch (error) {
|
||||
// RDNS lookup failed - not uncommon
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track failed login for pattern detection
|
||||
*/
|
||||
trackFailedLogin(ip, username) {
|
||||
if (!this.recentFailedLogins.has(ip)) {
|
||||
this.recentFailedLogins.set(ip, []);
|
||||
}
|
||||
|
||||
this.recentFailedLogins.get(ip).push({
|
||||
timestamp: Date.now(),
|
||||
username: username
|
||||
});
|
||||
|
||||
// Keep only last 24 hours
|
||||
const oneDayAgo = Date.now() - (24 * 60 * 60 * 1000);
|
||||
this.recentFailedLogins.set(ip,
|
||||
this.recentFailedLogins.get(ip).filter(attempt => attempt.timestamp > oneDayAgo)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track successful login for pattern detection
|
||||
*/
|
||||
trackSuccessfulLogin(ip) {
|
||||
if (!this.recentSuccessfulLogins.has(ip)) {
|
||||
this.recentSuccessfulLogins.set(ip, []);
|
||||
}
|
||||
|
||||
this.recentSuccessfulLogins.get(ip).push(Date.now());
|
||||
|
||||
// Keep only last 24 hours
|
||||
const oneDayAgo = Date.now() - (24 * 60 * 60 * 1000);
|
||||
this.recentSuccessfulLogins.set(ip,
|
||||
this.recentSuccessfulLogins.get(ip).filter(timestamp => timestamp > oneDayAgo)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for suspicious login patterns
|
||||
*/
|
||||
async checkSuspiciousPatterns(eventData) {
|
||||
const clientIP = eventData.client_ip || eventData.ip_address;
|
||||
|
||||
// Pattern 1: Successful login after multiple failed attempts
|
||||
if (eventData.event_type === 'login_success') {
|
||||
const failedAttempts = this.recentFailedLogins.get(clientIP) || [];
|
||||
const recentFailures = failedAttempts.filter(attempt =>
|
||||
attempt.timestamp > (Date.now() - 60 * 60 * 1000) // Last hour
|
||||
);
|
||||
|
||||
if (recentFailures.length >= 3) {
|
||||
await this.alertSuperAdmins('suspicious_login_pattern', {
|
||||
pattern: 'successful_after_failures',
|
||||
ip: clientIP,
|
||||
username: eventData.username,
|
||||
failed_attempts: recentFailures.length,
|
||||
country: eventData.country_name,
|
||||
message: `Successful login for ${eventData.username} from ${clientIP} after ${recentFailures.length} failed attempts`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 2: Multiple failed logins from different IPs to different usernames (distributed attack)
|
||||
await this.checkDistributedAttack();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for distributed brute force attacks
|
||||
*/
|
||||
async checkDistributedAttack() {
|
||||
const now = Date.now();
|
||||
const fiveMinutesAgo = now - (5 * 60 * 1000);
|
||||
|
||||
let totalFailedLogins = 0;
|
||||
let uniqueIPs = new Set();
|
||||
let uniqueUsernames = new Set();
|
||||
|
||||
for (const [ip, attempts] of this.recentFailedLogins.entries()) {
|
||||
const recentAttempts = attempts.filter(attempt => attempt.timestamp > fiveMinutesAgo);
|
||||
if (recentAttempts.length > 0) {
|
||||
totalFailedLogins += recentAttempts.length;
|
||||
uniqueIPs.add(ip);
|
||||
recentAttempts.forEach(attempt => uniqueUsernames.add(attempt.username));
|
||||
}
|
||||
}
|
||||
|
||||
// Alert if we see many failed logins from many IPs to many usernames
|
||||
if (totalFailedLogins >= 20 && uniqueIPs.size >= 5 && uniqueUsernames.size >= 5) {
|
||||
await this.alertSuperAdmins('distributed_attack', {
|
||||
pattern: 'distributed_brute_force',
|
||||
total_attempts: totalFailedLogins,
|
||||
unique_ips: uniqueIPs.size,
|
||||
unique_usernames: uniqueUsernames.size,
|
||||
message: `Distributed attack detected: ${totalFailedLogins} failed logins from ${uniqueIPs.size} IPs targeting ${uniqueUsernames.size} usernames in the last 5 minutes`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert super admins about suspicious activity
|
||||
*/
|
||||
async alertSuperAdmins(alertType, details) {
|
||||
try {
|
||||
// Log the alert as a critical security event
|
||||
await this.logSecurityEvent({
|
||||
event_type: 'security_alert',
|
||||
severity: 'critical',
|
||||
message: `SECURITY ALERT: ${details.message}`,
|
||||
metadata: {
|
||||
alert_type: alertType,
|
||||
...details
|
||||
},
|
||||
alerted: true
|
||||
});
|
||||
|
||||
// TODO: Send actual notifications (email, SMS, Slack, etc.)
|
||||
console.log(`🚨 SECURITY ALERT - ${alertType.toUpperCase()}:`, details.message);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to alert super admins:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escalate severity level for high-risk events
|
||||
*/
|
||||
escalateSeverity(currentSeverity) {
|
||||
const severityLevels = { low: 1, medium: 2, high: 3, critical: 4 };
|
||||
const reverseLevels = { 1: 'low', 2: 'medium', 3: 'high', 4: 'critical' };
|
||||
|
||||
const currentLevel = severityLevels[currentSeverity] || 1;
|
||||
const newLevel = Math.min(currentLevel + 1, 4);
|
||||
|
||||
return reverseLevels[newLevel];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old tracking data
|
||||
*/
|
||||
cleanupTrackingData() {
|
||||
const oneDayAgo = Date.now() - (24 * 60 * 60 * 1000);
|
||||
|
||||
for (const [ip, attempts] of this.recentFailedLogins.entries()) {
|
||||
const filtered = attempts.filter(attempt => attempt.timestamp > oneDayAgo);
|
||||
if (filtered.length === 0) {
|
||||
this.recentFailedLogins.delete(ip);
|
||||
} else {
|
||||
this.recentFailedLogins.set(ip, filtered);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [ip, timestamps] of this.recentSuccessfulLogins.entries()) {
|
||||
const filtered = timestamps.filter(timestamp => timestamp > oneDayAgo);
|
||||
if (filtered.length === 0) {
|
||||
this.recentSuccessfulLogins.delete(ip);
|
||||
} else {
|
||||
this.recentSuccessfulLogins.set(ip, filtered);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if IP recently had failed login followed by successful login
|
||||
*/
|
||||
hadRecentFailedAttempts(ip) {
|
||||
const failedAttempts = this.recentFailedLogins.get(ip) || [];
|
||||
const recentFailures = failedAttempts.filter(attempt =>
|
||||
attempt.timestamp > (Date.now() - 60 * 60 * 1000) // Last hour
|
||||
);
|
||||
|
||||
return recentFailures.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new SecurityLogService();
|
||||
Reference in New Issue
Block a user