WebOrbiton
v1.0.0.8

Publisium

90 lines · 3.2 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/database.php';
  6. ​
  7. final class LoginThrottle
  8. {
  9. private const WINDOW_SECONDS = 900;
  10. private const LIMIT_IDENTIFIER = 5;
  11. private const LIMIT_IP = 20;
  12. ​
  13. public static function secondsUntilAllowed(string $scope, string $identifier): int
  14. {
  15. return max(
  16. self::remaining('identifier_hash', $scope, self::hash($identifier), self::LIMIT_IDENTIFIER),
  17. self::remaining('ip_address', $scope, self::ip(), self::LIMIT_IP)
  18. );
  19. }
  20. ​
  21. public static function recordFailure(string $scope, string $identifier): void
  22. {
  23. try {
  24. $db = Database::site();
  25. $db->prepare('INSERT INTO login_attempts (scope, identifier_hash, ip_address) VALUES (:scope, :hash, :ip)')
  26. ->execute(['scope' => $scope, 'hash' => self::hash($identifier), 'ip' => self::ip()]);
  27. ​
  28. if (random_int(1, 50) === 1) {
  29. $db->exec('DELETE FROM login_attempts WHERE created_at < DATE_SUB(NOW(), INTERVAL 1 DAY)');
  30. }
  31. } catch (PDOException $e) {
  32. error_log('Publisium: could not record login attempt: ' . $e->getMessage());
  33. }
  34. }
  35. ​
  36. public static function clear(string $scope, string $identifier): void
  37. {
  38. try {
  39. Database::site()->prepare('DELETE FROM login_attempts WHERE scope = :scope AND identifier_hash = :hash')
  40. ->execute(['scope' => $scope, 'hash' => self::hash($identifier)]);
  41. } catch (PDOException $e) {
  42. error_log('Publisium: could not clear login attempts: ' . $e->getMessage());
  43. }
  44. }
  45. ​
  46. public static function message(int $seconds): string
  47. {
  48. $minutes = max(1, (int) ceil($seconds / 60));
  49. ​
  50. return 'Too many wrong tries. Please wait about ' . $minutes . ' ' . ($minutes === 1 ? 'minute' : 'minutes') . ' and try again.';
  51. }
  52. ​
  53. private static function remaining(string $column, string $scope, string $value, int $limit): int
  54. {
  55. if (!in_array($column, ['identifier_hash', 'ip_address'], true)) {
  56. return 0;
  57. }
  58. ​
  59. try {
  60. $statement = Database::site()->prepare(
  61. 'SELECT COUNT(*) AS attempts,
  62. TIMESTAMPDIFF(SECOND, NOW(), DATE_ADD(MIN(created_at), INTERVAL ' . self::WINDOW_SECONDS . ' SECOND)) AS remaining
  63. FROM (
  64. SELECT created_at FROM login_attempts
  65. WHERE scope = :scope AND ' . $column . ' = :value
  66. AND created_at > DATE_SUB(NOW(), INTERVAL ' . self::WINDOW_SECONDS . ' SECOND)
  67. ORDER BY created_at DESC
  68. LIMIT ' . $limit . '
  69. ) recent'
  70. );
  71. $statement->execute(['scope' => $scope, 'value' => $value]);
  72. $row = $statement->fetch();
  73. ​
  74. return (int) $row['attempts'] >= $limit ? max(1, (int) $row['remaining']) : 0;
  75. } catch (PDOException $e) {
  76. return 0;
  77. }
  78. }
  79. ​
  80. private static function hash(string $identifier): string
  81. {
  82. return hash('sha256', strtolower(trim($identifier)));
  83. }
  84. ​
  85. private static function ip(): string
  86. {
  87. return substr((string) ($_SERVER['REMOTE_ADDR'] ?? ''), 0, 45);
  88. }
  89. }
  90. ​