177 lines
4.9 KiB
PHP
177 lines
4.9 KiB
PHP
<?php
|
|
|
|
require_once __DIR__ . '/settings.php';
|
|
|
|
function qdb_login_rate_limit_path(): string
|
|
{
|
|
return dirname(QDB_PATH) . '/.login_rate_limit.json';
|
|
}
|
|
|
|
function qdb_client_ip(): string
|
|
{
|
|
$raw = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '';
|
|
if ($raw === '') {
|
|
return '0.0.0.0';
|
|
}
|
|
if (str_contains($raw, ',')) {
|
|
$raw = trim(explode(',', $raw)[0]);
|
|
}
|
|
return $raw;
|
|
}
|
|
|
|
function qdb_login_rate_limit_key(string $username): string
|
|
{
|
|
$ip = qdb_client_ip();
|
|
return hash('sha256', strtolower(trim($username)) . "\0" . $ip);
|
|
}
|
|
|
|
/**
|
|
* @return array{0: bool, 1: int} allowed, retry_after_seconds
|
|
*/
|
|
function qdb_login_rate_limit_check(string $username, ?array $settings = null): array
|
|
{
|
|
$settings ??= qdb_settings_get();
|
|
$max = (int)$settings['login_max_attempts'];
|
|
$window = (int)$settings['login_window_seconds'];
|
|
$lockout = (int)$settings['login_lockout_seconds'];
|
|
$now = time();
|
|
$key = qdb_login_rate_limit_key($username);
|
|
$path = qdb_login_rate_limit_path();
|
|
$dir = dirname($path);
|
|
if (!is_dir($dir)) {
|
|
@mkdir($dir, 0755, true);
|
|
}
|
|
|
|
$fp = @fopen($path, 'c+');
|
|
if ($fp === false) {
|
|
return [true, 0];
|
|
}
|
|
try {
|
|
if (!flock($fp, LOCK_EX)) {
|
|
return [true, 0];
|
|
}
|
|
$raw = stream_get_contents($fp);
|
|
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : [];
|
|
if (!is_array($data)) {
|
|
$data = [];
|
|
}
|
|
$entry = $data[$key] ?? ['attempts' => [], 'locked_until' => 0];
|
|
$lockedUntil = (int)($entry['locked_until'] ?? 0);
|
|
if ($lockedUntil > $now) {
|
|
return [false, $lockedUntil - $now];
|
|
}
|
|
$attempts = array_values(array_filter(
|
|
(array)($entry['attempts'] ?? []),
|
|
static fn($t) => is_int($t) && $t > $now - $window
|
|
));
|
|
if (count($attempts) >= $max) {
|
|
$entry['locked_until'] = $now + $lockout;
|
|
$entry['attempts'] = $attempts;
|
|
$data[$key] = $entry;
|
|
ftruncate($fp, 0);
|
|
rewind($fp);
|
|
fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE));
|
|
fflush($fp);
|
|
return [false, $lockout];
|
|
}
|
|
return [true, 0];
|
|
} finally {
|
|
flock($fp, LOCK_UN);
|
|
fclose($fp);
|
|
}
|
|
}
|
|
|
|
function qdb_login_rate_limit_record_failure(string $username, ?array $settings = null): int
|
|
{
|
|
$settings ??= qdb_settings_get();
|
|
$max = (int)$settings['login_max_attempts'];
|
|
$window = (int)$settings['login_window_seconds'];
|
|
$lockout = (int)$settings['login_lockout_seconds'];
|
|
$now = time();
|
|
$key = qdb_login_rate_limit_key($username);
|
|
$path = qdb_login_rate_limit_path();
|
|
$dir = dirname($path);
|
|
if (!is_dir($dir)) {
|
|
@mkdir($dir, 0755, true);
|
|
}
|
|
|
|
$fp = @fopen($path, 'c+');
|
|
if ($fp === false) {
|
|
return 0;
|
|
}
|
|
try {
|
|
if (!flock($fp, LOCK_EX)) {
|
|
return 0;
|
|
}
|
|
$raw = stream_get_contents($fp);
|
|
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : [];
|
|
if (!is_array($data)) {
|
|
$data = [];
|
|
}
|
|
$entry = $data[$key] ?? ['attempts' => [], 'locked_until' => 0];
|
|
$attempts = array_values(array_filter(
|
|
(array)($entry['attempts'] ?? []),
|
|
static fn($t) => is_int($t) && $t > $now - $window
|
|
));
|
|
$attempts[] = $now;
|
|
$retry = 0;
|
|
if (count($attempts) >= $max) {
|
|
$entry['locked_until'] = $now + $lockout;
|
|
$retry = $lockout;
|
|
}
|
|
$entry['attempts'] = $attempts;
|
|
$data[$key] = $entry;
|
|
ftruncate($fp, 0);
|
|
rewind($fp);
|
|
fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE));
|
|
fflush($fp);
|
|
return $retry;
|
|
} finally {
|
|
flock($fp, LOCK_UN);
|
|
fclose($fp);
|
|
}
|
|
}
|
|
|
|
function qdb_login_rate_limit_clear(string $username): void
|
|
{
|
|
$key = qdb_login_rate_limit_key($username);
|
|
$path = qdb_login_rate_limit_path();
|
|
if (!is_file($path)) {
|
|
return;
|
|
}
|
|
$fp = @fopen($path, 'c+');
|
|
if ($fp === false) {
|
|
return;
|
|
}
|
|
try {
|
|
if (!flock($fp, LOCK_EX)) {
|
|
return;
|
|
}
|
|
$raw = stream_get_contents($fp);
|
|
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : [];
|
|
if (!is_array($data) || !isset($data[$key])) {
|
|
return;
|
|
}
|
|
unset($data[$key]);
|
|
ftruncate($fp, 0);
|
|
rewind($fp);
|
|
fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE));
|
|
fflush($fp);
|
|
} finally {
|
|
flock($fp, LOCK_UN);
|
|
fclose($fp);
|
|
}
|
|
}
|
|
|
|
function qdb_login_rate_limit_deny(int $retryAfterSeconds): never
|
|
{
|
|
if ($retryAfterSeconds > 0) {
|
|
header('Retry-After: ' . (string)$retryAfterSeconds);
|
|
}
|
|
json_error(
|
|
'RATE_LIMITED',
|
|
'Too many login attempts. Please wait before trying again.',
|
|
429
|
|
);
|
|
}
|