loginSecurity.class.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. <?php
  2. /**
  3. * Classe loginSecurity
  4. *
  5. * Gère la sécurité avancée pour la page de login :
  6. * - Rate limiting (limitation du nombre de tentatives)
  7. * - Throttling (délai progressif après échecs)
  8. * - Logging des tentatives
  9. * - Verrouillage temporaire
  10. */
  11. class loginSecurity
  12. {
  13. // Configuration
  14. private const MAX_ATTEMPTS = 5; // Nombre max de tentatives avant blocage
  15. private const LOCKOUT_TIME = 900; // Durée du blocage en secondes (15 min)
  16. private const THROTTLE_BASE = 2; // Délai de base en secondes
  17. private const THROTTLE_MAX = 30; // Délai maximum en secondes
  18. private const LOG_TABLE = 'login_attempts'; // Table de log (si DB)
  19. /**
  20. * Vérifie si l'IP ou l'email est bloqué
  21. *
  22. * @param string $email Email tenté
  23. * @param string|null $ip Adresse IP (auto-détectée si null)
  24. * @return array ['blocked' => bool, 'remaining_time' => int, 'attempts' => int]
  25. */
  26. public static function checkBlocked(string $email = '', ?string $ip = null): array
  27. {
  28. $ip = $ip ?? self::getClientIP();
  29. $key = self::getCacheKey($ip, $email);
  30. // Récupérer les données de session
  31. $data = $_SESSION['login_security'][$key] ?? [
  32. 'attempts' => 0,
  33. 'last_attempt' => 0,
  34. 'locked_until' => 0
  35. ];
  36. // Vérifier si bloqué
  37. if ($data['locked_until'] > time()) {
  38. return [
  39. 'blocked' => true,
  40. 'remaining_time' => $data['locked_until'] - time(),
  41. 'attempts' => $data['attempts'],
  42. 'message' => self::getBlockedMessage($data['locked_until'] - time())
  43. ];
  44. }
  45. // Réinitialiser si le blocage est expiré
  46. if ($data['locked_until'] > 0 && $data['locked_until'] <= time()) {
  47. self::resetAttempts($ip, $email);
  48. $data['attempts'] = 0;
  49. }
  50. return [
  51. 'blocked' => false,
  52. 'remaining_time' => 0,
  53. 'attempts' => $data['attempts'],
  54. 'message' => ''
  55. ];
  56. }
  57. /**
  58. * Enregistre une tentative de connexion
  59. *
  60. * @param string $email Email tenté
  61. * @param bool $success Tentative réussie ou non
  62. * @param string|null $ip Adresse IP
  63. * @return array Statut après la tentative
  64. */
  65. public static function recordAttempt(string $email, bool $success, ?string $ip = null): array
  66. {
  67. $ip = $ip ?? self::getClientIP();
  68. $key = self::getCacheKey($ip, $email);
  69. // Log dans la base de données
  70. self::logAttempt($email, $ip, $success);
  71. // Si succès, réinitialiser les compteurs
  72. if ($success) {
  73. self::resetAttempts($ip, $email);
  74. return [
  75. 'blocked' => false,
  76. 'attempts' => 0,
  77. 'throttle_delay' => 0
  78. ];
  79. }
  80. // Incrémenter le compteur d'échecs
  81. $data = $_SESSION['login_security'][$key] ?? [
  82. 'attempts' => 0,
  83. 'last_attempt' => 0,
  84. 'locked_until' => 0
  85. ];
  86. $data['attempts']++;
  87. $data['last_attempt'] = time();
  88. // Vérifier si on doit bloquer
  89. if ($data['attempts'] >= self::MAX_ATTEMPTS) {
  90. $data['locked_until'] = time() + self::LOCKOUT_TIME;
  91. $_SESSION['login_security'][$key] = $data;
  92. // Log le blocage
  93. error_log(sprintf(
  94. "[SECURITY] IP %s blocked for %d seconds after %d failed attempts (email: %s)",
  95. $ip,
  96. self::LOCKOUT_TIME,
  97. $data['attempts'],
  98. $email
  99. ));
  100. return [
  101. 'blocked' => true,
  102. 'attempts' => $data['attempts'],
  103. 'throttle_delay' => 0,
  104. 'remaining_time' => self::LOCKOUT_TIME,
  105. 'message' => self::getBlockedMessage(self::LOCKOUT_TIME)
  106. ];
  107. }
  108. $_SESSION['login_security'][$key] = $data;
  109. // Calculer le délai de throttling
  110. $throttleDelay = self::calculateThrottleDelay($data['attempts']);
  111. return [
  112. 'blocked' => false,
  113. 'attempts' => $data['attempts'],
  114. 'remaining_attempts' => self::MAX_ATTEMPTS - $data['attempts'],
  115. 'throttle_delay' => $throttleDelay,
  116. 'message' => self::getWarningMessage($data['attempts'])
  117. ];
  118. }
  119. /**
  120. * Applique le délai de throttling (à appeler avant de traiter le login)
  121. *
  122. * @param string $email Email tenté
  123. * @param string|null $ip Adresse IP
  124. */
  125. public static function applyThrottle(string $email = '', ?string $ip = null): void
  126. {
  127. $ip = $ip ?? self::getClientIP();
  128. $key = self::getCacheKey($ip, $email);
  129. $data = $_SESSION['login_security'][$key] ?? ['attempts' => 0];
  130. if ($data['attempts'] > 0) {
  131. $delay = self::calculateThrottleDelay($data['attempts']);
  132. if ($delay > 0) {
  133. sleep($delay);
  134. }
  135. }
  136. }
  137. /**
  138. * Calcule le délai de throttling progressif
  139. *
  140. * @param int $attempts Nombre de tentatives
  141. * @return int Délai en secondes
  142. */
  143. private static function calculateThrottleDelay(int $attempts): int
  144. {
  145. if ($attempts <= 1) {
  146. return 0;
  147. }
  148. // Délai exponentiel : 2, 4, 8, 16, 30 (max)
  149. $delay = pow(self::THROTTLE_BASE, $attempts - 1);
  150. return min($delay, self::THROTTLE_MAX);
  151. }
  152. /**
  153. * Réinitialise les tentatives pour une IP/email
  154. *
  155. * @param string $ip Adresse IP
  156. * @param string $email Email
  157. */
  158. public static function resetAttempts(string $ip, string $email = ''): void
  159. {
  160. $key = self::getCacheKey($ip, $email);
  161. unset($_SESSION['login_security'][$key]);
  162. // Aussi réinitialiser par IP seule
  163. $keyIp = self::getCacheKey($ip, '');
  164. unset($_SESSION['login_security'][$keyIp]);
  165. }
  166. /**
  167. * Log une tentative dans la base de données
  168. *
  169. * @param string $email Email tenté
  170. * @param string $ip Adresse IP
  171. * @param bool $success Succès ou échec
  172. */
  173. private static function logAttempt(string $email, string $ip, bool $success): void
  174. {
  175. try {
  176. // Vérifier si la table existe
  177. $tableExists = db::bind("SHOW TABLES LIKE ?", [self::LOG_TABLE]);
  178. if (!empty($tableExists)) {
  179. db::bind(
  180. "INSERT INTO " . self::LOG_TABLE . " (email, ip_address, success, user_agent, attempted_at)
  181. VALUES (?, ?, ?, ?, NOW())",
  182. [
  183. $email,
  184. $ip,
  185. $success ? 1 : 0,
  186. $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown'
  187. ]
  188. );
  189. }
  190. } catch (Exception $e) {
  191. // Si la table n'existe pas, log dans le fichier
  192. error_log(sprintf(
  193. "[LOGIN] %s | IP: %s | Email: %s | Status: %s | UA: %s",
  194. date('Y-m-d H:i:s'),
  195. $ip,
  196. $email,
  197. $success ? 'SUCCESS' : 'FAILED',
  198. $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown'
  199. ));
  200. }
  201. }
  202. /**
  203. * Génère la clé de cache unique
  204. *
  205. * @param string $ip Adresse IP
  206. * @param string $email Email
  207. * @return string Clé de cache
  208. */
  209. private static function getCacheKey(string $ip, string $email): string
  210. {
  211. // Combiner IP + email hashé pour plus de sécurité
  212. $emailHash = $email ? md5(strtolower(trim($email))) : 'nomail';
  213. return "login_{$ip}_{$emailHash}";
  214. }
  215. /**
  216. * Récupère l'IP réelle du client
  217. *
  218. * @return string Adresse IP
  219. */
  220. public static function getClientIP(): string
  221. {
  222. $headers = [
  223. 'HTTP_CF_CONNECTING_IP', // Cloudflare
  224. 'HTTP_X_FORWARDED_FOR',
  225. 'HTTP_X_FORWARDED',
  226. 'HTTP_X_CLUSTER_CLIENT_IP',
  227. 'HTTP_FORWARDED_FOR',
  228. 'HTTP_FORWARDED',
  229. 'HTTP_CLIENT_IP',
  230. 'REMOTE_ADDR'
  231. ];
  232. foreach ($headers as $header) {
  233. if (!empty($_SERVER[$header])) {
  234. $ip = $_SERVER[$header];
  235. if (strpos($ip, ',') !== false) {
  236. $ips = explode(',', $ip);
  237. $ip = trim($ips[0]);
  238. }
  239. if (filter_var($ip, FILTER_VALIDATE_IP)) {
  240. return $ip;
  241. }
  242. }
  243. }
  244. return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
  245. }
  246. /**
  247. * Message de blocage formaté
  248. *
  249. * @param int $seconds Secondes restantes
  250. * @return string Message
  251. */
  252. private static function getBlockedMessage(int $seconds): string
  253. {
  254. $minutes = ceil($seconds / 60);
  255. if ($minutes <= 1) {
  256. return "Trop de tentatives. Réessayez dans $seconds secondes.";
  257. }
  258. return "Trop de tentatives. Réessayez dans $minutes minutes.";
  259. }
  260. /**
  261. * Message d'avertissement
  262. *
  263. * @param int $attempts Nombre de tentatives
  264. * @return string Message
  265. */
  266. private static function getWarningMessage(int $attempts): string
  267. {
  268. $remaining = self::MAX_ATTEMPTS - $attempts;
  269. if ($remaining <= 2) {
  270. return "Attention : $remaining tentative(s) restante(s) avant blocage.";
  271. }
  272. return '';
  273. }
  274. /**
  275. * Vérifie un honeypot (champ caché anti-bot)
  276. *
  277. * @param string $fieldName Nom du champ honeypot
  278. * @return bool True si le honeypot est vide (utilisateur légitime)
  279. */
  280. public static function checkHoneypot(string $fieldName = 'website'): bool
  281. {
  282. // Le champ honeypot doit être VIDE
  283. // Les bots le remplissent souvent automatiquement
  284. if (isset($_POST[$fieldName]) && !empty($_POST[$fieldName])) {
  285. error_log(sprintf(
  286. "[SECURITY] Honeypot triggered | IP: %s | Field: %s | Value: %s",
  287. self::getClientIP(),
  288. $fieldName,
  289. substr($_POST[$fieldName], 0, 50)
  290. ));
  291. return false;
  292. }
  293. return true;
  294. }
  295. /**
  296. * Obtient les statistiques de tentatives pour une IP
  297. *
  298. * @param string|null $ip Adresse IP
  299. * @return array Statistiques
  300. */
  301. public static function getStats(?string $ip = null): array
  302. {
  303. $ip = $ip ?? self::getClientIP();
  304. $stats = [
  305. 'ip' => $ip,
  306. 'total_attempts' => 0,
  307. 'blocked_keys' => []
  308. ];
  309. if (isset($_SESSION['login_security'])) {
  310. foreach ($_SESSION['login_security'] as $key => $data) {
  311. if (strpos($key, $ip) !== false) {
  312. $stats['total_attempts'] += $data['attempts'] ?? 0;
  313. if (isset($data['locked_until']) && $data['locked_until'] > time()) {
  314. $stats['blocked_keys'][] = $key;
  315. }
  316. }
  317. }
  318. }
  319. return $stats;
  320. }
  321. /**
  322. * Nettoie les anciennes entrées de session
  323. */
  324. public static function cleanup(): void
  325. {
  326. if (!isset($_SESSION['login_security'])) {
  327. return;
  328. }
  329. $now = time();
  330. foreach ($_SESSION['login_security'] as $key => $data) {
  331. // Supprimer les entrées expirées depuis plus d'1 heure
  332. if (isset($data['locked_until']) && $data['locked_until'] > 0) {
  333. if ($data['locked_until'] + 3600 < $now) {
  334. unset($_SESSION['login_security'][$key]);
  335. }
  336. } elseif (isset($data['last_attempt']) && $data['last_attempt'] + 3600 < $now) {
  337. unset($_SESSION['login_security'][$key]);
  338. }
  339. }
  340. }
  341. }