Services: - EmailService: CSIRT notifications (24h/72h/30d), training alerts, welcome email - RateLimitService: File-based rate limiting for auth and AI endpoints - ReportService: Executive HTML report, CSV exports (risks/incidents/controls/assets) Integrations: - AuthController: Rate limiting on login (5/min, 20/h) and register (3/10min) - IncidentController: Email notifications on CSIRT milestones - AuditController: Executive report and CSV export endpoints - Router: 429 rate limit error handling, new audit export routes Database: - Migration 002: email_log table for notification tracking Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
215 lines
6.8 KiB
PHP
215 lines
6.8 KiB
PHP
<?php
|
|
/**
|
|
* NIS2 Agile - Rate Limit Service
|
|
*
|
|
* Rate limiting basato su file per proteggere gli endpoint API da abusi.
|
|
* Supporta finestre multiple per endpoint (es. 5/min + 20/ora).
|
|
* Storage: /tmp/nis2_ratelimit/{md5(key)}.json
|
|
*/
|
|
|
|
class RateLimitService
|
|
{
|
|
private const STORAGE_DIR = '/tmp/nis2_ratelimit';
|
|
|
|
/**
|
|
* Verifica il rate limit e lancia eccezione se superato
|
|
*
|
|
* @param string $key Identificatore univoco (es. "login:192.168.1.1", "ai:user_42")
|
|
* @param array $limits Array di finestre [['max' => int, 'window_seconds' => int], ...]
|
|
* @throws RuntimeException Se il rate limit è superato
|
|
*/
|
|
public static function check(string $key, array $limits): void
|
|
{
|
|
$timestamps = self::loadTimestamps($key);
|
|
$now = time();
|
|
|
|
foreach ($limits as $limit) {
|
|
$windowStart = $now - $limit['window_seconds'];
|
|
$hitsInWindow = count(array_filter($timestamps, fn(int $ts): bool => $ts > $windowStart));
|
|
|
|
if ($hitsInWindow >= $limit['max']) {
|
|
// Calcola secondi rimanenti fino alla scadenza del hit più vecchio nella finestra
|
|
$oldestInWindow = array_values(array_filter($timestamps, fn(int $ts): bool => $ts > $windowStart));
|
|
sort($oldestInWindow);
|
|
$retryAfter = ($oldestInWindow[0] ?? $now) + $limit['window_seconds'] - $now;
|
|
$retryAfter = max(1, $retryAfter);
|
|
|
|
throw new RuntimeException(
|
|
"Troppi tentativi. Riprova tra {$retryAfter} secondi.",
|
|
429
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Registra un hit per la chiave specificata
|
|
*
|
|
* @param string $key Identificatore univoco
|
|
*/
|
|
public static function increment(string $key): void
|
|
{
|
|
$timestamps = self::loadTimestamps($key);
|
|
$timestamps[] = time();
|
|
|
|
self::saveTimestamps($key, $timestamps);
|
|
}
|
|
|
|
/**
|
|
* Verifica se il rate limit è superato senza lanciare eccezione
|
|
*
|
|
* @param string $key Identificatore univoco
|
|
* @param array $limits Array di finestre
|
|
* @return bool True se il limite è superato
|
|
*/
|
|
public static function isExceeded(string $key, array $limits): bool
|
|
{
|
|
try {
|
|
self::check($key, $limits);
|
|
return false;
|
|
} catch (RuntimeException) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restituisce i tentativi rimanenti per ogni finestra
|
|
*
|
|
* @param string $key Identificatore univoco
|
|
* @param array $limits Array di finestre
|
|
* @return array Array di ['max' => int, 'window_seconds' => int, 'remaining' => int, 'reset_at' => int]
|
|
*/
|
|
public static function getRemaining(string $key, array $limits): array
|
|
{
|
|
$timestamps = self::loadTimestamps($key);
|
|
$now = time();
|
|
$result = [];
|
|
|
|
foreach ($limits as $limit) {
|
|
$windowStart = $now - $limit['window_seconds'];
|
|
$hitsInWindow = array_filter($timestamps, fn(int $ts): bool => $ts > $windowStart);
|
|
$hitCount = count($hitsInWindow);
|
|
|
|
// Calcola quando si resetta la finestra (scadenza del hit più vecchio)
|
|
$resetAt = $now + $limit['window_seconds'];
|
|
if (!empty($hitsInWindow)) {
|
|
$oldest = min($hitsInWindow);
|
|
$resetAt = $oldest + $limit['window_seconds'];
|
|
}
|
|
|
|
$result[] = [
|
|
'max' => $limit['max'],
|
|
'window_seconds' => $limit['window_seconds'],
|
|
'remaining' => max(0, $limit['max'] - $hitCount),
|
|
'reset_at' => $resetAt,
|
|
];
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Pulisce i file di rate limit scaduti
|
|
* Da chiamare periodicamente (es. cron ogni 10 minuti)
|
|
*/
|
|
public static function cleanup(): void
|
|
{
|
|
$dir = self::STORAGE_DIR;
|
|
|
|
if (!is_dir($dir)) {
|
|
return;
|
|
}
|
|
|
|
$now = time();
|
|
// Finestra massima ragionevole: 1 ora. Elimina file non modificati da più di 2 ore
|
|
$maxAge = 7200;
|
|
|
|
$files = glob($dir . '/*.json');
|
|
if ($files === false) {
|
|
return;
|
|
}
|
|
|
|
foreach ($files as $file) {
|
|
$mtime = filemtime($file);
|
|
if ($mtime !== false && ($now - $mtime) > $maxAge) {
|
|
@unlink($file);
|
|
continue;
|
|
}
|
|
|
|
// Pulisci anche i timestamp scaduti all'interno dei file ancora attivi
|
|
$content = @file_get_contents($file);
|
|
if ($content === false) {
|
|
continue;
|
|
}
|
|
|
|
$timestamps = json_decode($content, true);
|
|
if (!is_array($timestamps)) {
|
|
@unlink($file);
|
|
continue;
|
|
}
|
|
|
|
$cutoff = $now - $maxAge;
|
|
$filtered = array_values(array_filter($timestamps, fn(int $ts): bool => $ts > $cutoff));
|
|
|
|
if (empty($filtered)) {
|
|
@unlink($file);
|
|
} else {
|
|
@file_put_contents($file, json_encode($filtered), LOCK_EX);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Metodi privati
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Carica i timestamp dal file di storage
|
|
*/
|
|
private static function loadTimestamps(string $key): array
|
|
{
|
|
$file = self::getFilePath($key);
|
|
|
|
if (!file_exists($file)) {
|
|
return [];
|
|
}
|
|
|
|
$content = @file_get_contents($file);
|
|
if ($content === false) {
|
|
return [];
|
|
}
|
|
|
|
$timestamps = json_decode($content, true);
|
|
|
|
return is_array($timestamps) ? $timestamps : [];
|
|
}
|
|
|
|
/**
|
|
* Salva i timestamp nel file di storage
|
|
*/
|
|
private static function saveTimestamps(string $key, array $timestamps): void
|
|
{
|
|
$dir = self::STORAGE_DIR;
|
|
|
|
if (!is_dir($dir)) {
|
|
if (!mkdir($dir, 0700, true) && !is_dir($dir)) {
|
|
throw new RuntimeException("Impossibile creare directory rate limit: {$dir}");
|
|
}
|
|
}
|
|
|
|
$file = self::getFilePath($key);
|
|
|
|
if (file_put_contents($file, json_encode($timestamps), LOCK_EX) === false) {
|
|
throw new RuntimeException("Impossibile scrivere file rate limit: {$file}");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Genera il percorso del file per una chiave
|
|
*/
|
|
private static function getFilePath(string $key): string
|
|
{
|
|
return self::STORAGE_DIR . '/' . md5($key) . '.json';
|
|
}
|
|
}
|