monacousa-portal/server/utils/security.ts

135 lines
4.1 KiB
TypeScript

// Simple in-memory rate limiting (for production, consider Redis or database)
const loginAttempts = new Map<string, { count: number; lastAttempt: number }>();
const blockedIPs = new Map<string, number>(); // IP -> blocked until timestamp
export const checkRateLimit = (ip: string): { allowed: boolean; attemptsLeft: number } => {
const now = Date.now();
const maxAttempts = 5;
const windowMs = 15 * 60 * 1000; // 15 minutes
const blockDurationMs = 60 * 60 * 1000; // 1 hour
// Check if IP is currently blocked
const blockedUntil = blockedIPs.get(ip);
if (blockedUntil && now < blockedUntil) {
return { allowed: false, attemptsLeft: 0 };
}
// Remove expired block
if (blockedUntil && now >= blockedUntil) {
blockedIPs.delete(ip);
}
// Get current attempts for this IP
const attempts = loginAttempts.get(ip);
// Clean up old attempts outside the window
if (attempts && (now - attempts.lastAttempt) > windowMs) {
loginAttempts.delete(ip);
return { allowed: true, attemptsLeft: maxAttempts };
}
const currentCount = attempts?.count || 0;
if (currentCount >= maxAttempts) {
// Block the IP
blockedIPs.set(ip, now + blockDurationMs);
console.warn(`🚨 IP ${ip} blocked due to ${currentCount} failed login attempts`);
return { allowed: false, attemptsLeft: 0 };
}
return { allowed: true, attemptsLeft: maxAttempts - currentCount };
};
export const recordFailedAttempt = (ip: string): void => {
const now = Date.now();
const attempts = loginAttempts.get(ip);
if (attempts) {
attempts.count += 1;
attempts.lastAttempt = now;
} else {
loginAttempts.set(ip, { count: 1, lastAttempt: now });
}
const newCount = loginAttempts.get(ip)?.count || 1;
console.warn(`⚠️ Failed login attempt from ${ip} (${newCount}/5)`);
};
export const clearFailedAttempts = (ip: string): void => {
loginAttempts.delete(ip);
blockedIPs.delete(ip);
console.log(`✅ Cleared failed attempts for ${ip}`);
};
// Input validation
export const validateLoginInput = (username: string, password: string): string[] => {
const errors: string[] = [];
if (!username || typeof username !== 'string') {
errors.push('Username is required');
} else if (username.length < 2) {
errors.push('Username must be at least 2 characters');
} else if (username.length > 100) {
errors.push('Username is too long');
}
if (!password || typeof password !== 'string') {
errors.push('Password is required');
} else if (password.length < 6) {
errors.push('Password must be at least 6 characters');
} else if (password.length > 200) {
errors.push('Password is too long');
}
// Basic sanitization check
const dangerousChars = /<script|javascript:|data:|vbscript:/i;
if (dangerousChars.test(username) || dangerousChars.test(password)) {
errors.push('Invalid characters detected');
}
return errors;
};
// Get client IP helper
export const getClientIP = (event: any): string => {
// Try various headers that might contain the real IP
const headers = getHeaders(event);
return (
headers['x-forwarded-for']?.split(',')[0]?.trim() ||
headers['x-real-ip'] ||
headers['x-client-ip'] ||
headers['cf-connecting-ip'] || // Cloudflare
event.node?.req?.connection?.remoteAddress ||
event.node?.req?.socket?.remoteAddress ||
'unknown'
);
};
// Clean up old entries periodically (call this from a cron job or similar)
export const cleanupOldEntries = (): void => {
const now = Date.now();
const windowMs = 15 * 60 * 1000; // 15 minutes
// Clean up old login attempts
for (const [ip, attempts] of loginAttempts.entries()) {
if ((now - attempts.lastAttempt) > windowMs) {
loginAttempts.delete(ip);
}
}
// Clean up expired blocks
for (const [ip, blockedUntil] of blockedIPs.entries()) {
if (now >= blockedUntil) {
blockedIPs.delete(ip);
}
}
console.log('🧹 Cleaned up old security entries');
};
// Initialize cleanup interval (runs every 5 minutes)
if (typeof setInterval !== 'undefined') {
setInterval(cleanupOldEntries, 5 * 60 * 1000);
}