WebOrbiton
v1.0.0.0

StocketBase

94 lines · 3.3 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('StocketBase: 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('StocketBase: 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. $template = class_exists('Language')
  51. ? Language::get('auth_too_many_attempts', 'Too many failed attempts. Try again in about %d min.')
  52. : 'Too many failed attempts. Try again in about %d min.';
  53. ​
  54. return sprintf($template, $minutes);
  55. }
  56. ​
  57. private static function remaining(string $column, string $scope, string $value, int $limit): int
  58. {
  59. if (!in_array($column, ['identifier_hash', 'ip_address'], true)) {
  60. return 0;
  61. }
  62. ​
  63. try {
  64. $statement = Database::site()->prepare(
  65. 'SELECT COUNT(*) AS attempts,
  66. TIMESTAMPDIFF(SECOND, NOW(), DATE_ADD(MIN(created_at), INTERVAL ' . self::WINDOW_SECONDS . ' SECOND)) AS remaining
  67. FROM (
  68. SELECT created_at FROM login_attempts
  69. WHERE scope = :scope AND ' . $column . ' = :value
  70. AND created_at > DATE_SUB(NOW(), INTERVAL ' . self::WINDOW_SECONDS . ' SECOND)
  71. ORDER BY created_at DESC
  72. LIMIT ' . $limit . '
  73. ) recent'
  74. );
  75. $statement->execute(['scope' => $scope, 'value' => $value]);
  76. $row = $statement->fetch();
  77. ​
  78. return (int) $row['attempts'] >= $limit ? max(1, (int) $row['remaining']) : 0;
  79. } catch (PDOException $e) {
  80. return 0;
  81. }
  82. }
  83. ​
  84. private static function hash(string $identifier): string
  85. {
  86. return hash('sha256', strtolower(trim($identifier)));
  87. }
  88. ​
  89. private static function ip(): string
  90. {
  91. return substr((string) ($_SERVER['REMOTE_ADDR'] ?? ''), 0, 45);
  92. }
  93. }
  94. ​