Files
nat-as-server/lib/login_rate_limit.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
);
}