v1.0.0.1
StocketBase
- <?php
-
- declare(strict_types=1);
-
- require_once __DIR__ . '/database.php';
-
- final class LoginThrottle
- {
- private const WINDOW_SECONDS = 900;
- private const LIMIT_IDENTIFIER = 5;
- private const LIMIT_IP = 20;
-
- public static function secondsUntilAllowed(string $scope, string $identifier): int
- {
- return max(
- self::remaining('identifier_hash', $scope, self::hash($identifier), self::LIMIT_IDENTIFIER),
- self::remaining('ip_address', $scope, self::ip(), self::LIMIT_IP)
- );
- }
-
- public static function recordFailure(string $scope, string $identifier): void
- {
- try {
- $db = Database::site();
- $db->prepare('INSERT INTO login_attempts (scope, identifier_hash, ip_address) VALUES (:scope, :hash, :ip)')
- ->execute(['scope' => $scope, 'hash' => self::hash($identifier), 'ip' => self::ip()]);
-
- if (random_int(1, 50) === 1) {
- $db->exec('DELETE FROM login_attempts WHERE created_at < DATE_SUB(NOW(), INTERVAL 1 DAY)');
- }
- } catch (PDOException $e) {
- error_log('StocketBase: could not record login attempt: ' . $e->getMessage());
- }
- }
-
- public static function clear(string $scope, string $identifier): void
- {
- try {
- Database::site()->prepare('DELETE FROM login_attempts WHERE scope = :scope AND identifier_hash = :hash')
- ->execute(['scope' => $scope, 'hash' => self::hash($identifier)]);
- } catch (PDOException $e) {
- error_log('StocketBase: could not clear login attempts: ' . $e->getMessage());
- }
- }
-
- public static function message(int $seconds): string
- {
- $minutes = max(1, (int) ceil($seconds / 60));
-
- $template = class_exists('Language')
- ? Language::get('auth_too_many_attempts', 'Too many failed attempts. Try again in about %d min.')
- : 'Too many failed attempts. Try again in about %d min.';
-
- return sprintf($template, $minutes);
- }
-
- private static function remaining(string $column, string $scope, string $value, int $limit): int
- {
- if (!in_array($column, ['identifier_hash', 'ip_address'], true)) {
- return 0;
- }
-
- try {
- $statement = Database::site()->prepare(
- 'SELECT COUNT(*) AS attempts,
- TIMESTAMPDIFF(SECOND, NOW(), DATE_ADD(MIN(created_at), INTERVAL ' . self::WINDOW_SECONDS . ' SECOND)) AS remaining
- FROM (
- SELECT created_at FROM login_attempts
- WHERE scope = :scope AND ' . $column . ' = :value
- AND created_at > DATE_SUB(NOW(), INTERVAL ' . self::WINDOW_SECONDS . ' SECOND)
- ORDER BY created_at DESC
- LIMIT ' . $limit . '
- ) recent'
- );
- $statement->execute(['scope' => $scope, 'value' => $value]);
- $row = $statement->fetch();
-
- return (int) $row['attempts'] >= $limit ? max(1, (int) $row['remaining']) : 0;
- } catch (PDOException $e) {
- return 0;
- }
- }
-
- private static function hash(string $identifier): string
- {
- return hash('sha256', strtolower(trim($identifier)));
- }
-
- private static function ip(): string
- {
- return substr((string) ($_SERVER['REMOTE_ADDR'] ?? ''), 0, 45);
- }
- }
-