jilo-web/app/classes/ratelimiter.php

458 lines
16 KiB
PHP

<?php
class RateLimiter {
public $db;
private $log;
public $maxAttempts = 5; // Maximum login attempts
public $decayMinutes = 15; // Time window in minutes
public $autoBlacklistThreshold = 10; // Attempts before auto-blacklist
public $autoBlacklistDuration = 24; // Hours to blacklist for
public $ratelimitTable = 'login_attempts';
public $whitelistTable = 'ip_whitelist';
public $blacklistTable = 'ip_blacklist';
public function __construct($database) {
$this->db = $database->getConnection();
$this->log = new Log($database);
$this->createTablesIfNotExist();
}
// Database preparation
private function createTablesIfNotExist() {
// Login attempts table
$sql = "CREATE TABLE IF NOT EXISTS {$this->ratelimitTable} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL UNIQUE,
username TEXT NOT NULL,
attempted_at TEXT DEFAULT (DATETIME('now'))
)";
$this->db->exec($sql);
// IP whitelist table
$sql = "CREATE TABLE IF NOT EXISTS {$this->whitelistTable} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL UNIQUE,
is_network BOOLEAN DEFAULT 0 CHECK(is_network IN (0,1)),
description TEXT,
created_at TEXT DEFAULT (DATETIME('now')),
created_by TEXT
)";
$this->db->exec($sql);
// IP blacklist table
$sql = "CREATE TABLE IF NOT EXISTS {$this->blacklistTable} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL UNIQUE,
is_network BOOLEAN DEFAULT 0 CHECK(is_network IN (0,1)),
reason TEXT,
expiry_time TEXT NULL,
created_at TEXT DEFAULT (DATETIME('now')),
created_by TEXT
)";
$this->db->exec($sql);
// Default IPs to whitelist (local interface and private networks IPs)
$defaultIps = [
['127.0.0.1', false, 'localhost IPv4'],
['::1', false, 'localhost IPv6'],
['10.0.0.0/8', true, 'Private network (Class A)'],
['172.16.0.0/12', true, 'Private network (Class B)'],
['192.168.0.0/16', true, 'Private network (Class C)']
];
// Insert default whitelisted IPs if they don't exist
$stmt = $this->db->prepare("INSERT OR IGNORE INTO {$this->whitelistTable}
(ip_address, is_network, description, created_by)
VALUES (?, ?, ?, 'system')");
foreach ($defaultIps as $ip) {
$stmt->execute([$ip[0], $ip[1], $ip[2]]);
}
// Insert known malicious networks
$defaultBlacklist = [
['0.0.0.0/8', true, 'Reserved address space - RFC 1122'],
['100.64.0.0/10', true, 'Carrier-grade NAT space - RFC 6598'],
['192.0.2.0/24', true, 'TEST-NET-1 Documentation space - RFC 5737'],
['198.51.100.0/24', true, 'TEST-NET-2 Documentation space - RFC 5737'],
['203.0.113.0/24', true, 'TEST-NET-3 Documentation space - RFC 5737']
];
$stmt = $this->db->prepare("INSERT OR IGNORE INTO {$this->blacklistTable}
(ip_address, is_network, reason, created_by)
VALUES (?, ?, ?, 'system')");
foreach ($defaultBlacklist as $ip) {
$stmt->execute([$ip[0], $ip[1], $ip[2]]);
}
}
/**
* Get number of recent login attempts for an IP
*/
public function getRecentAttempts($ip) {
$stmt = $this->db->prepare("SELECT COUNT(*) as attempts FROM {$this->ratelimitTable}
WHERE ip_address = ? AND attempted_at > datetime('now', '-' || :minutes || ' minutes')");
$stmt->execute([$ip, $this->decayMinutes]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return intval($result['attempts']);
}
/**
* Check if an IP is blacklisted
*/
public function isIpBlacklisted($ip) {
// First check if IP is explicitly blacklisted or in a blacklisted range
$stmt = $this->db->prepare("SELECT ip_address, is_network, expiry_time FROM {$this->blacklistTable}");
$stmt->execute();
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
// Skip expired entries
if ($row['expiry_time'] !== null && strtotime($row['expiry_time']) < time()) {
continue;
}
if ($row['is_network']) {
if ($this->ipInRange($ip, $row['ip_address'])) {
return true;
}
} else {
if ($ip === $row['ip_address']) {
return true;
}
}
}
return false;
}
/**
* Check if an IP is whitelisted
*/
public function isIpWhitelisted($ip) {
// Check exact IP match and CIDR ranges
$stmt = $this->db->prepare("SELECT ip_address, is_network FROM {$this->whitelistTable}");
$stmt->execute();
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if ($row['is_network']) {
if ($this->ipInRange($ip, $row['ip_address'])) {
return true;
}
} else {
if ($ip === $row['ip_address']) {
return true;
}
}
}
return false;
}
private function ipInRange($ip, $cidr) {
list($subnet, $bits) = explode('/', $cidr);
$ip = ip2long($ip);
$subnet = ip2long($subnet);
$mask = -1 << (32 - $bits);
$subnet &= $mask;
return ($ip & $mask) == $subnet;
}
// Add to whitelist
public function addToWhitelist($ip, $isNetwork = false, $description = '', $createdBy = 'system', $userId = null) {
try {
// Check if IP is blacklisted first
if ($this->isIpBlacklisted($ip)) {
$message = "Cannot whitelist {$ip} - IP is currently blacklisted";
if ($userId) {
$this->log->insertLog($userId, "IP Whitelist: {$message}", 'system');
Messages::flash('ERROR', 'DEFAULT', $message);
}
return false;
}
$stmt = $this->db->prepare("INSERT OR REPLACE INTO {$this->whitelistTable}
(ip_address, is_network, description, created_by)
VALUES (?, ?, ?, ?)");
$result = $stmt->execute([$ip, $isNetwork, $description, $createdBy]);
if ($result) {
$logMessage = sprintf(
'IP Whitelist: Added %s "%s" by %s. Description: %s',
$isNetwork ? 'network' : 'IP',
$ip,
$createdBy,
$description
);
$this->log->insertLog($userId ?? 0, $logMessage, 'system');
}
return $result;
} catch (Exception $e) {
if ($userId) {
$this->log->insertLog($userId, "IP Whitelist: Failed to add {$ip}: " . $e->getMessage(), 'system');
Messages::flash('ERROR', 'DEFAULT', "IP Whitelist: Failed to add {$ip}: " . $e->getMessage());
}
return false;
}
}
// Remove from whitelist
public function removeFromWhitelist($ip, $removedBy = 'system', $userId = null) {
try {
// Get IP details before removal for logging
$stmt = $this->db->prepare("SELECT * FROM {$this->whitelistTable} WHERE ip_address = ?");
$stmt->execute([$ip]);
$ipDetails = $stmt->fetch(PDO::FETCH_ASSOC);
// Remove the IP
$stmt = $this->db->prepare("DELETE FROM {$this->whitelistTable} WHERE ip_address = ?");
$result = $stmt->execute([$ip]);
if ($result && $ipDetails) {
$logMessage = sprintf(
'IP Whitelist: Removed %s "%s" by %s. Was added by: %s',
$ipDetails['is_network'] ? 'network' : 'IP',
$ip,
$removedBy,
$ipDetails['created_by']
);
$this->log->insertLog($userId ?? 0, $logMessage, 'system');
}
return $result;
} catch (Exception $e) {
if ($userId) {
$this->log->insertLog($userId, "IP Whitelist: Failed to remove {$ip}: " . $e->getMessage(), 'system');
Messages::flash('ERROR', 'DEFAULT', "IP Whitelist: Failed to remove {$ip}: " . $e->getMessage());
}
return false;
}
}
public function addToBlacklist($ip, $isNetwork = false, $reason = '', $createdBy = 'system', $userId = null, $expiryHours = null) {
try {
// Check if IP is whitelisted first
if ($this->isIpWhitelisted($ip)) {
$message = "Cannot blacklist {$ip} - IP is currently whitelisted";
if ($userId) {
$this->log->insertLog($userId, "IP Blacklist: {$message}", 'system');
Messages::flash('ERROR', 'DEFAULT', $message);
}
return false;
}
$expiryTime = $expiryHours ? date('Y-m-d H:i:s', strtotime("+{$expiryHours} hours")) : null;
$stmt = $this->db->prepare("INSERT OR REPLACE INTO {$this->blacklistTable}
(ip_address, is_network, reason, expiry_time, created_by)
VALUES (?, ?, ?, ?, ?)");
$result = $stmt->execute([$ip, $isNetwork, $reason, $expiryTime, $createdBy]);
if ($result) {
$logMessage = sprintf(
'IP Blacklist: Added %s "%s" by %s. Reason: %s. Expires: %s',
$isNetwork ? 'network' : 'IP',
$ip,
$createdBy,
$reason,
$expiryTime ?? 'never'
);
$this->log->insertLog($userId ?? 0, $logMessage, 'system');
}
return $result;
} catch (Exception $e) {
if ($userId) {
$this->log->insertLog($userId, "IP Blacklist: Failed to add {$ip}: " . $e->getMessage(), 'system');
Messages::flash('ERROR', 'DEFAULT', "IP Blacklist: Failed to add {$ip}: " . $e->getMessage());
}
return false;
}
}
public function removeFromBlacklist($ip, $removedBy = 'system', $userId = null) {
try {
// Get IP details before removal for logging
$stmt = $this->db->prepare("SELECT * FROM {$this->blacklistTable} WHERE ip_address = ?");
$stmt->execute([$ip]);
$ipDetails = $stmt->fetch(PDO::FETCH_ASSOC);
// Remove the IP
$stmt = $this->db->prepare("DELETE FROM {$this->blacklistTable} WHERE ip_address = ?");
$result = $stmt->execute([$ip]);
if ($result && $ipDetails) {
$logMessage = sprintf(
'IP Blacklist: Removed %s "%s" by %s. Was added by: %s. Reason was: %s',
$ipDetails['is_network'] ? 'network' : 'IP',
$ip,
$removedBy,
$ipDetails['created_by'],
$ipDetails['reason']
);
$this->log->insertLog($userId ?? 0, $logMessage, 'system');
}
return $result;
} catch (Exception $e) {
if ($userId) {
$this->log->insertLog($userId, "IP Blacklist: Failed to remove {$ip}: " . $e->getMessage(), 'system');
Messages::flash('ERROR', 'DEFAULT', "IP Blacklist: Failed to remove {$ip}: " . $e->getMessage());
}
return false;
}
}
public function getWhitelistedIps() {
$stmt = $this->db->prepare("SELECT * FROM {$this->whitelistTable} ORDER BY created_at DESC");
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function getBlacklistedIps() {
$stmt = $this->db->prepare("SELECT * FROM {$this->blacklistTable} ORDER BY created_at DESC");
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function cleanupExpiredEntries() {
try {
// Remove expired blacklist entries
$stmt = $this->db->prepare("DELETE FROM {$this->blacklistTable}
WHERE expiry_time IS NOT NULL AND expiry_time < datetime('now')");
$stmt->execute();
// Clean old login attempts
$stmt = $this->db->prepare("DELETE FROM {$this->ratelimitTable}
WHERE attempted_at < datetime('now', '-' || :minutes || ' minutes')");
$stmt->execute([':minutes' => $this->decayMinutes]);
return true;
} catch (Exception $e) {
$this->log->insertLog(0, "Failed to cleanup expired entries: " . $e->getMessage(), 'system');
Messages::flash('ERROR', 'DEFAULT', "Failed to cleanup expired entries: " . $e->getMessage());
return false;
}
}
public function isAllowed($username, $ipAddress) {
// First check if IP is blacklisted
if ($this->isIpBlacklisted($ipAddress)) {
return false;
}
// Then check if IP is whitelisted
if ($this->isIpWhitelisted($ipAddress)) {
return true;
}
// Clean old attempts
$this->clearOldAttempts();
// Check if we've hit the rate limit
if ($this->tooManyAttempts($username, $ipAddress)) {
return false;
}
// Check total attempts across all usernames from this IP
$sql = "SELECT COUNT(*) as total_attempts
FROM {$this->ratelimitTable}
WHERE ip_address = :ip
AND attempted_at > datetime('now', '-' || :minutes || ' minutes')";
$stmt = $this->db->prepare($sql);
$stmt->execute([
':ip' => $ipAddress,
':minutes' => $this->decayMinutes
]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
// Check if we would hit auto-blacklist threshold
return $result['total_attempts'] < $this->autoBlacklistThreshold;
}
public function attempt($username, $ipAddress) {
// Record this attempt
$sql = "INSERT INTO {$this->ratelimitTable} (ip_address, username) VALUES (:ip, :username)";
$stmt = $this->db->prepare($sql);
$stmt->execute([
':ip' => $ipAddress,
':username' => $username
]);
// Auto-blacklist if too many attempts
if (!$this->isAllowed($username, $ipAddress)) {
$this->addToBlacklist(
$ipAddress,
false,
'Auto-blacklisted due to excessive login attempts',
'system',
null,
$this->autoBlacklistDuration
);
return false;
}
return true;
}
public function tooManyAttempts($username, $ipAddress) {
$sql = "SELECT COUNT(*) as attempts
FROM {$this->ratelimitTable}
WHERE ip_address = :ip
AND username = :username
AND attempted_at > datetime('now', '-' || :minutes || ' minutes')";
$stmt = $this->db->prepare($sql);
$stmt->execute([
':ip' => $ipAddress,
':username' => $username,
':minutes' => $this->decayMinutes
]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return $result['attempts'] >= $this->maxAttempts;
}
public function clearOldAttempts() {
$sql = "DELETE FROM {$this->ratelimitTable}
WHERE attempted_at < datetime('now', '-' || :minutes || ' minutes')";
$stmt = $this->db->prepare($sql);
$stmt->execute([
':minutes' => $this->decayMinutes
]);
}
public function getRemainingAttempts($username, $ipAddress) {
$sql = "SELECT COUNT(*) as attempts
FROM {$this->ratelimitTable}
WHERE ip_address = :ip
AND username = :username
AND attempted_at > datetime('now', '-' || :minutes || ' minutes')";
$stmt = $this->db->prepare($sql);
$stmt->execute([
':ip' => $ipAddress,
':username' => $username,
':minutes' => $this->decayMinutes
]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return max(0, $this->maxAttempts - $result['attempts']);
}
public function getDecayMinutes() {
return $this->decayMinutes;
}
}