bool, 'remaining_time' => int, 'attempts' => int] */ public static function checkBlocked(string $email = '', ?string $ip = null): array { $ip = $ip ?? self::getClientIP(); $key = self::getCacheKey($ip, $email); // Récupérer les données de session $data = $_SESSION['login_security'][$key] ?? [ 'attempts' => 0, 'last_attempt' => 0, 'locked_until' => 0 ]; // Vérifier si bloqué if ($data['locked_until'] > time()) { return [ 'blocked' => true, 'remaining_time' => $data['locked_until'] - time(), 'attempts' => $data['attempts'], 'message' => self::getBlockedMessage($data['locked_until'] - time()) ]; } // Réinitialiser si le blocage est expiré if ($data['locked_until'] > 0 && $data['locked_until'] <= time()) { self::resetAttempts($ip, $email); $data['attempts'] = 0; } return [ 'blocked' => false, 'remaining_time' => 0, 'attempts' => $data['attempts'], 'message' => '' ]; } /** * Enregistre une tentative de connexion * * @param string $email Email tenté * @param bool $success Tentative réussie ou non * @param string|null $ip Adresse IP * @return array Statut après la tentative */ public static function recordAttempt(string $email, bool $success, ?string $ip = null): array { $ip = $ip ?? self::getClientIP(); $key = self::getCacheKey($ip, $email); // Log dans la base de données self::logAttempt($email, $ip, $success); // Si succès, réinitialiser les compteurs if ($success) { self::resetAttempts($ip, $email); return [ 'blocked' => false, 'attempts' => 0, 'throttle_delay' => 0 ]; } // Incrémenter le compteur d'échecs $data = $_SESSION['login_security'][$key] ?? [ 'attempts' => 0, 'last_attempt' => 0, 'locked_until' => 0 ]; $data['attempts']++; $data['last_attempt'] = time(); // Vérifier si on doit bloquer if ($data['attempts'] >= self::MAX_ATTEMPTS) { $data['locked_until'] = time() + self::LOCKOUT_TIME; $_SESSION['login_security'][$key] = $data; // Log le blocage error_log(sprintf( "[SECURITY] IP %s blocked for %d seconds after %d failed attempts (email: %s)", $ip, self::LOCKOUT_TIME, $data['attempts'], $email )); return [ 'blocked' => true, 'attempts' => $data['attempts'], 'throttle_delay' => 0, 'remaining_time' => self::LOCKOUT_TIME, 'message' => self::getBlockedMessage(self::LOCKOUT_TIME) ]; } $_SESSION['login_security'][$key] = $data; // Calculer le délai de throttling $throttleDelay = self::calculateThrottleDelay($data['attempts']); return [ 'blocked' => false, 'attempts' => $data['attempts'], 'remaining_attempts' => self::MAX_ATTEMPTS - $data['attempts'], 'throttle_delay' => $throttleDelay, 'message' => self::getWarningMessage($data['attempts']) ]; } /** * Applique le délai de throttling (à appeler avant de traiter le login) * * @param string $email Email tenté * @param string|null $ip Adresse IP */ public static function applyThrottle(string $email = '', ?string $ip = null): void { $ip = $ip ?? self::getClientIP(); $key = self::getCacheKey($ip, $email); $data = $_SESSION['login_security'][$key] ?? ['attempts' => 0]; if ($data['attempts'] > 0) { $delay = self::calculateThrottleDelay($data['attempts']); if ($delay > 0) { sleep($delay); } } } /** * Calcule le délai de throttling progressif * * @param int $attempts Nombre de tentatives * @return int Délai en secondes */ private static function calculateThrottleDelay(int $attempts): int { if ($attempts <= 1) { return 0; } // Délai exponentiel : 2, 4, 8, 16, 30 (max) $delay = pow(self::THROTTLE_BASE, $attempts - 1); return min($delay, self::THROTTLE_MAX); } /** * Réinitialise les tentatives pour une IP/email * * @param string $ip Adresse IP * @param string $email Email */ public static function resetAttempts(string $ip, string $email = ''): void { $key = self::getCacheKey($ip, $email); unset($_SESSION['login_security'][$key]); // Aussi réinitialiser par IP seule $keyIp = self::getCacheKey($ip, ''); unset($_SESSION['login_security'][$keyIp]); } /** * Log une tentative dans la base de données * * @param string $email Email tenté * @param string $ip Adresse IP * @param bool $success Succès ou échec */ private static function logAttempt(string $email, string $ip, bool $success): void { try { // Vérifier si la table existe $tableExists = db::bind("SHOW TABLES LIKE ?", [self::LOG_TABLE]); if (!empty($tableExists)) { db::bind( "INSERT INTO " . self::LOG_TABLE . " (email, ip_address, success, user_agent, attempted_at) VALUES (?, ?, ?, ?, NOW())", [ $email, $ip, $success ? 1 : 0, $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown' ] ); } } catch (Exception $e) { // Si la table n'existe pas, log dans le fichier error_log(sprintf( "[LOGIN] %s | IP: %s | Email: %s | Status: %s | UA: %s", date('Y-m-d H:i:s'), $ip, $email, $success ? 'SUCCESS' : 'FAILED', $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown' )); } } /** * Génère la clé de cache unique * * @param string $ip Adresse IP * @param string $email Email * @return string Clé de cache */ private static function getCacheKey(string $ip, string $email): string { // Combiner IP + email hashé pour plus de sécurité $emailHash = $email ? md5(strtolower(trim($email))) : 'nomail'; return "login_{$ip}_{$emailHash}"; } /** * Récupère l'IP réelle du client * * @return string Adresse IP */ public static function getClientIP(): string { $headers = [ 'HTTP_CF_CONNECTING_IP', // Cloudflare 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED', 'HTTP_X_CLUSTER_CLIENT_IP', 'HTTP_FORWARDED_FOR', 'HTTP_FORWARDED', 'HTTP_CLIENT_IP', 'REMOTE_ADDR' ]; foreach ($headers as $header) { if (!empty($_SERVER[$header])) { $ip = $_SERVER[$header]; if (strpos($ip, ',') !== false) { $ips = explode(',', $ip); $ip = trim($ips[0]); } if (filter_var($ip, FILTER_VALIDATE_IP)) { return $ip; } } } return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; } /** * Message de blocage formaté * * @param int $seconds Secondes restantes * @return string Message */ private static function getBlockedMessage(int $seconds): string { $minutes = ceil($seconds / 60); if ($minutes <= 1) { return "Trop de tentatives. Réessayez dans $seconds secondes."; } return "Trop de tentatives. Réessayez dans $minutes minutes."; } /** * Message d'avertissement * * @param int $attempts Nombre de tentatives * @return string Message */ private static function getWarningMessage(int $attempts): string { $remaining = self::MAX_ATTEMPTS - $attempts; if ($remaining <= 2) { return "Attention : $remaining tentative(s) restante(s) avant blocage."; } return ''; } /** * Vérifie un honeypot (champ caché anti-bot) * * @param string $fieldName Nom du champ honeypot * @return bool True si le honeypot est vide (utilisateur légitime) */ public static function checkHoneypot(string $fieldName = 'website'): bool { // Le champ honeypot doit être VIDE // Les bots le remplissent souvent automatiquement if (isset($_POST[$fieldName]) && !empty($_POST[$fieldName])) { error_log(sprintf( "[SECURITY] Honeypot triggered | IP: %s | Field: %s | Value: %s", self::getClientIP(), $fieldName, substr($_POST[$fieldName], 0, 50) )); return false; } return true; } /** * Obtient les statistiques de tentatives pour une IP * * @param string|null $ip Adresse IP * @return array Statistiques */ public static function getStats(?string $ip = null): array { $ip = $ip ?? self::getClientIP(); $stats = [ 'ip' => $ip, 'total_attempts' => 0, 'blocked_keys' => [] ]; if (isset($_SESSION['login_security'])) { foreach ($_SESSION['login_security'] as $key => $data) { if (strpos($key, $ip) !== false) { $stats['total_attempts'] += $data['attempts'] ?? 0; if (isset($data['locked_until']) && $data['locked_until'] > time()) { $stats['blocked_keys'][] = $key; } } } } return $stats; } /** * Nettoie les anciennes entrées de session */ public static function cleanup(): void { if (!isset($_SESSION['login_security'])) { return; } $now = time(); foreach ($_SESSION['login_security'] as $key => $data) { // Supprimer les entrées expirées depuis plus d'1 heure if (isset($data['locked_until']) && $data['locked_until'] > 0) { if ($data['locked_until'] + 3600 < $now) { unset($_SESSION['login_security'][$key]); } } elseif (isset($data['last_attempt']) && $data['last_attempt'] + 3600 < $now) { unset($_SESSION['login_security'][$key]); } } } }