nis2-agile/application/services/RateLimitService.php
Cristiano Benassati 6f4b457ce0 [FEAT] Add EmailService, RateLimitService, ReportService + integrations
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>
2026-02-17 19:12:46 +01:00

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';
}
}