|
|
@@ -0,0 +1,380 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+/**
|
|
|
+ * Classe loginSecurity
|
|
|
+ *
|
|
|
+ * Gère la sécurité avancée pour la page de login :
|
|
|
+ * - Rate limiting (limitation du nombre de tentatives)
|
|
|
+ * - Throttling (délai progressif après échecs)
|
|
|
+ * - Logging des tentatives
|
|
|
+ * - Verrouillage temporaire
|
|
|
+ */
|
|
|
+class loginSecurity
|
|
|
+{
|
|
|
+ // Configuration
|
|
|
+ private const MAX_ATTEMPTS = 5; // Nombre max de tentatives avant blocage
|
|
|
+ private const LOCKOUT_TIME = 900; // Durée du blocage en secondes (15 min)
|
|
|
+ private const THROTTLE_BASE = 2; // Délai de base en secondes
|
|
|
+ private const THROTTLE_MAX = 30; // Délai maximum en secondes
|
|
|
+ private const LOG_TABLE = 'login_attempts'; // Table de log (si DB)
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Vérifie si l'IP ou l'email est bloqué
|
|
|
+ *
|
|
|
+ * @param string $email Email tenté
|
|
|
+ * @param string|null $ip Adresse IP (auto-détectée si null)
|
|
|
+ * @return array ['blocked' => 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]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|