added session revocation and settings menu for security measures
This commit is contained in:
176
lib/login_rate_limit.php
Normal file
176
lib/login_rate_limit.php
Normal file
@ -0,0 +1,176 @@
|
||||
<?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
|
||||
);
|
||||
}
|
||||
114
lib/settings.php
Normal file
114
lib/settings.php
Normal file
@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Persisted security settings (system_setting table in encrypted DB).
|
||||
*/
|
||||
|
||||
function qdb_settings_defaults(): array
|
||||
{
|
||||
return [
|
||||
'login_max_attempts' => 10,
|
||||
'login_window_seconds' => 900,
|
||||
'login_lockout_seconds' => 900,
|
||||
'session_ttl_seconds' => 30 * 24 * 60 * 60,
|
||||
'temp_session_ttl_seconds' => 10 * 60,
|
||||
];
|
||||
}
|
||||
|
||||
function qdb_settings_keys(): array
|
||||
{
|
||||
return array_keys(qdb_settings_defaults());
|
||||
}
|
||||
|
||||
function qdb_ensure_system_setting_table(PDO $pdo): void
|
||||
{
|
||||
if (!qdb_table_exists($pdo, 'system_setting')) {
|
||||
$pdo->exec("
|
||||
CREATE TABLE system_setting (
|
||||
settingKey TEXT NOT NULL PRIMARY KEY,
|
||||
settingValue TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
");
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_settings_get_on_pdo(PDO $pdo): array
|
||||
{
|
||||
qdb_ensure_system_setting_table($pdo);
|
||||
$out = qdb_settings_defaults();
|
||||
$rows = $pdo->query('SELECT settingKey, settingValue FROM system_setting')->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as $row) {
|
||||
$key = (string)($row['settingKey'] ?? '');
|
||||
if ($key === '' || !array_key_exists($key, $out)) {
|
||||
continue;
|
||||
}
|
||||
$out[$key] = (int)$row['settingValue'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function qdb_settings_get(): array
|
||||
{
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
try {
|
||||
return qdb_settings_get_on_pdo($pdo);
|
||||
} finally {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $partial
|
||||
* @param array<string, int>|null $base existing settings; defaults used when null
|
||||
* @return array<string, int> saved settings
|
||||
*/
|
||||
function qdb_settings_validate_and_merge(array $partial, ?array $base = null): array
|
||||
{
|
||||
$merged = $base ?? qdb_settings_defaults();
|
||||
$limits = [
|
||||
'login_max_attempts' => [1, 100],
|
||||
'login_window_seconds' => [60, 86400],
|
||||
'login_lockout_seconds' => [60, 86400],
|
||||
'session_ttl_seconds' => [3600, 365 * 24 * 60 * 60],
|
||||
'temp_session_ttl_seconds' => [300, 24 * 60 * 60],
|
||||
];
|
||||
foreach (qdb_settings_keys() as $key) {
|
||||
if (!array_key_exists($key, $partial)) {
|
||||
continue;
|
||||
}
|
||||
$val = (int)$partial[$key];
|
||||
[$min, $max] = $limits[$key];
|
||||
if ($val < $min || $val > $max) {
|
||||
throw new InvalidArgumentException(
|
||||
"$key must be between $min and $max"
|
||||
);
|
||||
}
|
||||
$merged[$key] = $val;
|
||||
}
|
||||
return $merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $settings full merged settings to persist
|
||||
*/
|
||||
function qdb_settings_save_on_pdo(PDO $pdo, array $settings): void
|
||||
{
|
||||
qdb_ensure_system_setting_table($pdo);
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO system_setting (settingKey, settingValue)
|
||||
VALUES (:k, :v)
|
||||
ON CONFLICT(settingKey) DO UPDATE SET settingValue = excluded.settingValue'
|
||||
);
|
||||
foreach (qdb_settings_keys() as $key) {
|
||||
$stmt->execute([':k' => $key, ':v' => (string)(int)($settings[$key] ?? 0)]);
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_session_ttl_seconds(?array $settings = null, bool $temp = false): int
|
||||
{
|
||||
$settings ??= qdb_settings_get();
|
||||
return $temp
|
||||
? (int)$settings['temp_session_ttl_seconds']
|
||||
: (int)$settings['session_ttl_seconds'];
|
||||
}
|
||||
Reference in New Issue
Block a user