Redis + RateLimiting

This commit is contained in:
Roman 2024-04-23 20:14:32 +02:00
parent c715dadb11
commit 6c39c292b0
15 changed files with 330 additions and 48 deletions

@ -5,6 +5,7 @@ namespace Core\API;
use Core\Driver\Logger\Logger;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\TwoFactorToken;
use Core\Objects\RateLimiting;
use Core\Objects\TwoFactor\KeyBasedTwoFactorToken;
use PhpMqtt\Client\MqttClient;
@ -23,6 +24,7 @@ abstract class Request {
protected bool $isDisabled;
protected bool $apiKeyAllowed;
protected bool $csrfTokenRequired;
protected ?RateLimiting $rateLimiting;
private array $defaultParams;
private array $allowedMethods;
@ -47,6 +49,7 @@ abstract class Request {
$this->apiKeyAllowed = true;
$this->allowedMethods = array("GET", "POST");
$this->csrfTokenRequired = true;
$this->rateLimiting = null;
}
public function getAPIName(): string {
@ -192,7 +195,26 @@ abstract class Request {
$this->result['logoutIn'] = $session->getExpiresSeconds();
}
if ($this->isDisabled) {
$this->lastError = "This function is currently disabled.";
http_response_code(503);
return false;
}
$sql = $this->context->getSQL();
if ($sql === null || !$sql->isConnected()) {
$this->lastError = $sql ? $sql->getLastError() : "Database not connected yet.";
http_response_code(503);
return false;
}
if ($this->externalCall) {
if (!$this->isPublic) {
$this->lastError = 'This function is private.';
http_response_code(403);
return false;
}
$values = $_REQUEST;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array("application/json", explode(";", $_SERVER["CONTENT_TYPE"] ?? ""))) {
$jsonData = json_decode(file_get_contents('php://input'), true);
@ -204,21 +226,6 @@ abstract class Request {
return false;
}
}
}
if ($this->isDisabled) {
$this->lastError = "This function is currently disabled.";
http_response_code(503);
return false;
}
if ($this->externalCall && !$this->isPublic) {
$this->lastError = 'This function is private.';
http_response_code(403);
return false;
}
if ($this->externalCall) {
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204); # No content
@ -292,10 +299,15 @@ abstract class Request {
$this->parseVariableParams($values);
}
$sql = $this->context->getSQL();
if ($sql === null || !$sql->isConnected()) {
$this->lastError = $sql ? $sql->getLastError() : "Database not connected yet.";
return false;
if ($this->externalCall && $this->rateLimiting) {
$settings = $this->context->getSettings();
if ($settings->isRateLimitingEnabled()) {
if (!$this->rateLimiting->check($this->context, self::getEndpoint())) {
http_response_code(429);
$this->lastError = "Rate limit exceeded";
return false;
}
}
}
$this->success = true;

@ -58,6 +58,8 @@ namespace Core\API\TFA {
use Core\API\Parameter\StringType;
use Core\API\TfaAPI;
use Core\Objects\Context;
use Core\Objects\RateLimiting;
use Core\Objects\RateLimitRule;
use Core\Objects\TwoFactor\AttestationObject;
use Core\Objects\TwoFactor\AuthenticationData;
use Core\Objects\TwoFactor\KeyBasedTwoFactorToken;
@ -214,6 +216,10 @@ namespace Core\API\TFA {
]);
$this->loginRequired = true;
$this->csrfTokenRequired = false;
$this->rateLimiting = new RateLimiting(
null,
new RateLimitRule(5, 30, RateLimitRule::SECOND)
);
}
public function _execute(): bool {
@ -347,6 +353,10 @@ namespace Core\API\TFA {
]);
$this->loginRequired = true;
$this->csrfTokenRequired = false;
$this->rateLimiting = new RateLimiting(
null,
new RateLimitRule(20, 60, RateLimitRule::SECOND)
);
}
public function _execute(): bool {

@ -29,6 +29,7 @@ trait Pagination {
$this->paginationCondition = $condition;
$this->entityCount = call_user_func("$this->paginationClass::count", $sql, $condition, $joins);
$this->pageSize = $this->getParam("count");
$this->page = $this->getParam("page");
if ($this->entityCount === false) {
return $this->createError("Error fetching $this->paginationClass::count: " . $sql->getLastError());
}

@ -137,6 +137,8 @@ namespace Core\API\User {
use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Condition\CondIn;
use Core\Driver\SQL\Expression\JsonArrayAgg;
use Core\Objects\RateLimiting;
use Core\Objects\RateLimitRule;
use Core\Objects\TwoFactor\KeyBasedTwoFactorToken;
use ImagickException;
use Core\Objects\Context;
@ -563,6 +565,9 @@ namespace Core\API\User {
'token' => new StringType('token', 36)
));
$this->csrfTokenRequired = false;
$this->rateLimiting = new RateLimiting(
new RateLimitRule(5, 1, RateLimitRule::MINUTE)
);
}
public function _execute(): bool {
@ -601,8 +606,6 @@ namespace Core\API\User {
class Login extends UserAPI {
private int $startedAt;
public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall, array(
'username' => new StringType('username'),
@ -610,13 +613,9 @@ namespace Core\API\User {
'stayLoggedIn' => new Parameter('stayLoggedIn', Parameter::TYPE_BOOLEAN, true, false)
));
$this->forbidMethod("GET");
}
private function wrongCredentials(): bool {
$runtime = microtime(true) - $this->startedAt;
$sleepTime = round(3e6 - $runtime);
if ($sleepTime > 0) usleep($sleepTime);
return $this->createError(L('Wrong username or password'));
$this->rateLimiting = new RateLimiting(
new RateLimitRule(10, 30, RateLimitRule::SECOND)
);
}
public function _execute(): bool {
@ -635,7 +634,6 @@ namespace Core\API\User {
return true;
}
$this->startedAt = microtime(true);
$this->success = false;
$username = $this->getParam('username');
$password = $this->getParam('password');
@ -648,7 +646,7 @@ namespace Core\API\User {
if ($user !== false) {
if ($user === null) {
return $this->wrongCredentials();
return $this->createError(L('Wrong username or password'));
} else if (!$user->isActive()) {
return $this->createError("This user is currently disabled. Contact the server administrator, if you believe this is a mistake.");
} else if (password_verify($password, $user->password)) {
@ -668,7 +666,7 @@ namespace Core\API\User {
$this->success = true;
}
} else {
return $this->wrongCredentials();
return $this->createError(L('Wrong username or password'));
}
} else {
return $this->createError("Error fetching user details: " . $sql->getLastError());
@ -1190,14 +1188,18 @@ namespace Core\API\User {
class ResetPassword extends UserAPI {
public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall, array(
parent::__construct($context, $externalCall, [
'token' => new StringType('token', 36),
'password' => new StringType('password'),
'confirmPassword' => new StringType('confirmPassword'),
));
]);
$this->forbidMethod("GET");
$this->csrfTokenRequired = false;
$this->apiKeyAllowed = false;
$this->rateLimiting = new RateLimiting(
new RateLimitRule(5, 1, RateLimitRule::MINUTE)
);
}
public function _execute(): bool {

@ -15,6 +15,7 @@ use Core\Driver\SQL\SQL;
use Core\Objects\Captcha\CaptchaProvider;
use Core\Objects\Captcha\GoogleRecaptchaProvider;
use Core\Objects\Captcha\HCaptchaProvider;
use Core\Objects\ConnectionData;
use Core\Objects\Context;
class Settings {
@ -41,6 +42,12 @@ class Settings {
private string $mailFooter;
private bool $mailAsync;
// rate limiting
private bool $rateLimitingEnabled;
private string $redisHost;
private int $redisPort;
private string $redisPassword;
//
private Logger $logger;
@ -112,6 +119,18 @@ class Settings {
$settings->mailFooter = "";
$settings->mailAsync = false;
// rate limiting
$settings->redisPort = 6379;
if (isDocker()) {
$settings->rateLimitingEnabled = true;
$settings->redisHost = "webbase-redis";
$settings->redisPassword = "webbase-redis";
} else {
$settings->rateLimitingEnabled = false;
$settings->redisHost = "";
$settings->redisPassword = "";
}
return $settings;
}
@ -136,6 +155,10 @@ class Settings {
$this->mailAsync = $result["mail_async"] ?? $this->mailAsync;
$this->allowedExtensions = $result["allowed_extensions"] ?? $this->allowedExtensions;
$this->trustedDomains = $result["trusted_domains"] ?? $this->trustedDomains;
$this->rateLimitingEnabled = $result["rate_limiting_enabled"] ?? $this->rateLimitingEnabled;
$this->redisHost = $result["redis_host"] ?? $this->redisHost;
$this->redisPort = $result["redis_port"] ?? $this->redisPort;
$this->redisPassword = $result["redis_password"] ?? $this->redisPassword;
date_default_timezone_set($this->timeZone);
}
@ -160,7 +183,12 @@ class Settings {
->addRow("mail_from", '""', false, false)
->addRow("mail_last_sync", '""', false, false)
->addRow("mail_footer", '""', false, false)
->addRow("mail_async", false, false, false);
->addRow("mail_async", false, false, false)
->addRow("rate_limiting_enabled", json_encode($this->allowedExtensions), false, false)
->addRow("redis_host", json_encode($this->redisHost), false, false)
->addRow("redis_port", json_encode($this->redisPort), false, false)
->addRow("redis_password", json_encode($this->redisPassword), true, false)
;
}
public function getSiteName(): string {
@ -240,4 +268,17 @@ class Settings {
public function getTrustedDomains(): array {
return $this->trustedDomains;
}
public function isRateLimitingEnabled(): bool {
return $this->rateLimitingEnabled;
}
public function getRedisConfiguration(): ConnectionData {
return new ConnectionData(
$this->redisHost,
$this->redisPort,
"",
$this->redisPassword
);
}
}

@ -19,8 +19,11 @@ class InfoBody extends SimpleBody {
protected function getContent(): string {
$user = $this->getContext()->getUser();
if ($user && $user->hasGroup(Group::ADMIN)) {
ob_start();
phpinfo();
return "";
$content = ob_get_contents();
ob_end_clean();
return $content;
} else {
$message = "You are not logged in or do not have the proper privileges to access this page.";
return $this->getDocument()->getRouter()->returnStatusCode(403, [ "message" => $message] );

@ -93,10 +93,6 @@ namespace Documents\Install {
$this->steps = array();
}
function isDocker(): bool {
return file_exists("/.dockerenv");
}
private function getParameter($name): ?string {
if (isset($_REQUEST[$name]) && is_string($_REQUEST[$name])) {
return trim($_REQUEST[$name]);
@ -263,6 +259,11 @@ namespace Documents\Install {
$success = false;
}
if (!class_exists("Redis")) {
$failedRequirements[] = "<b>redis</b> extension is not installed.";
$success = false;
}
if (!function_exists("yaml_emit")) {
$failedRequirements[] = "<b>YAML</b> extension is not installed.";
$success = false;
@ -363,7 +364,7 @@ namespace Documents\Install {
$connectionData->setProperty('database', $database);
$connectionData->setProperty('encoding', $encoding);
$connectionData->setProperty('type', $type);
$connectionData->setProperty('isDocker', $this->isDocker());
$connectionData->setProperty('isDocker', isDocker());
$sql = SQL::createConnection($connectionData);
$success = false;
if (is_string($sql)) {
@ -706,11 +707,17 @@ namespace Documents\Install {
private function createProgressMainview(): string {
$isDocker = $this->isDocker();
$defaultHost = ($isDocker ? "db" : "localhost");
$defaultUsername = ($isDocker ? "root" : "");
$defaultPassword = ($isDocker ? "webbasedb" : "");
$defaultDatabase = ($isDocker ? "webbase" : "");
if (isDocker()) {
$defaultHost = "db";
$defaultUsername = "root";
$defaultPassword = "webbasedb";
$defaultDatabase = "webbase";
} else {
$defaultHost = "localhost";
$defaultUsername = "";
$defaultPassword = "";
$defaultDatabase = "";
}
$views = array(
self::CHECKING_REQUIREMENTS => array(

@ -0,0 +1,66 @@
<?php
namespace Core\Driver\Redis;
use Core\Driver\Logger\Logger;
use Core\Driver\SQL\SQL;
use Core\Objects\ConnectionData;
class RedisConnection {
private \Redis $link;
private Logger $logger;
public function __construct(?SQL $sql) {
$this->logger = new Logger("Redis", $sql);
$this->link = new \Redis();
}
public function connect(ConnectionData $connectionData): bool {
try {
$this->link->connect($connectionData->getHost(), $connectionData->getPort());
$this->link->auth($connectionData->getPassword());
return true;
} catch (\RedisException $e) {
$this->logger->error("Error connecting to redis: " . $e->getMessage());
return false;
}
}
public function isConnected(): bool {
try {
return $this->link->isConnected();
} catch (\RedisException $e) {
$this->logger->error("Error checking redis connection: " . $e->getMessage());
return false;
}
}
public function hGet(string $hashKey, string $key): ?string {
try {
return $this->link->hGet($hashKey, $key);
} catch (\RedisException $e) {
$this->logger->error("Error fetching value from redis: " . $e->getMessage());
return null;
}
}
public function hSet(string $hashKey, mixed $key, string $value): bool {
try {
return $this->link->hSet($hashKey, $key, $value);
} catch (\RedisException $e) {
$this->logger->error("Error setting value: " . $e->getMessage());
return false;
}
}
public function close(): bool {
try {
return $this->link->close();
} catch (\RedisException) {
return false;
}
}
}

@ -10,12 +10,12 @@ class ConnectionData {
private string $password;
private array $properties;
public function __construct($host, $port, $login, $password) {
public function __construct(string $host, int $port, string $login, string $password) {
$this->host = $host;
$this->port = $port;
$this->login = $login;
$this->password = $password;
$this->properties = array();
$this->properties = [];
}
public function getProperties(): array {

@ -4,6 +4,7 @@ namespace Core\Objects;
use Core\Configuration\Configuration;
use Core\Configuration\Settings;
use Core\Driver\Redis\RedisConnection;
use Core\Driver\SQL\Column\Column;
use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Condition\CondLike;
@ -25,6 +26,7 @@ class Context {
private Configuration $configuration;
private Language $language;
public ?Router $router;
private ?RedisConnection $redis;
private function __construct() {
@ -32,6 +34,7 @@ class Context {
$this->session = null;
$this->user = null;
$this->router = null;
$this->redis = null;
$this->configuration = new Configuration();
$this->setLanguage(Language::DEFAULT_LANGUAGE());
@ -52,6 +55,11 @@ class Context {
$this->sql->close();
$this->sql = null;
}
if ($this->redis && $this->redis->isConnected()) {
$this->redis->close();
$this->redis = null;
}
}
public function setLanguage(Language $language): void {
@ -201,4 +209,18 @@ class Context {
return $query->execute();
}
public function getRedis(): ?RedisConnection {
if ($this->redis === null) {
$settings = $this->getSettings();
$connectionData = $settings->getRedisConfiguration();
$this->redis = new RedisConnection($this->sql);
if (!$this->redis->connect($connectionData)) {
$this->redis = null;
}
}
return $this->redis;
}
}

@ -0,0 +1,31 @@
<?php
namespace Core\Objects;
class RateLimitRule {
const SECOND = 0;
const MINUTE = 1;
const HOUR = 2;
private int $count;
private int $perSecond;
public function __construct(int $count, int $time, int $unit) {
$this->count = $count;
$this->perSecond = $time;
if ($unit === self::HOUR) {
$this->perSecond *= 60 * 60;
} else if ($unit === self::MINUTE) {
$this->perSecond *= 60;
}
}
public function getCount(): int {
return $this->count;
}
public function getWindow(): int {
return $this->perSecond;
}
}

@ -0,0 +1,71 @@
<?php
namespace Core\Objects;
use Core\Driver\Logger\Logger;
class RateLimiting {
private ?RateLimitRule $anonymousRule;
private ?RateLimitRule $sessionRule;
public function __construct(?RateLimitRule $anonymousRule = null, ?RateLimitRule $sessionRule = null) {
$this->anonymousRule = $anonymousRule;
$this->sessionRule = $sessionRule;
}
public function check(Context $context, string $method): bool {
$session = $context->getSession();
$logger = new Logger("RateLimiting", $context->getSQL());
if ($session !== null) {
// session based rate limiting
$key = $session->getUUID();
$effectiveRule = $this->sessionRule;
} else {
// ip-based rate limiting
$key = $_SERVER['REMOTE_ADDR'];
$effectiveRule = $this->anonymousRule;
}
if ($effectiveRule === null) {
return true;
}
$redis = $context->getRedis();
if (!$redis?->isConnected()) {
$logger->error("Could not check rate limiting, redis is not connected.");
return true;
}
$now = time();
$queue = json_decode($redis->hGet($method, $key)) ?? [];
$pass = true;
$queueSize = count($queue);
if ($queueSize >= $effectiveRule->getCount()) {
// check the last n entries, whether they fit in the current window
$requestsInWindow = 0;
foreach ($queue as $accessTime) {
if ($accessTime >= $now - $effectiveRule->getWindow()) {
$requestsInWindow++;
if ($requestsInWindow >= $effectiveRule->getCount()) {
$pass = false;
break;
}
} else {
break;
}
}
}
if ($pass) {
array_unshift($queue, $now);
if ($queueSize + 1 > $effectiveRule->getCount()) {
$queue = array_slice($queue, 0, $effectiveRule->getCount());
}
$redis->hSet($method, $key, json_encode($queue));
}
return $pass;
}
}

@ -259,6 +259,10 @@ function getClassName($class, bool $short = true): string {
}
}
function isDocker(): bool {
return file_exists("/.dockerenv");
}
function createError($msg): array {
return ["success" => false, "msg" => $msg];
}

@ -29,3 +29,15 @@ services:
context: './docker/php/'
links:
- db
- cache
cache:
container_name: webbase-redis
image: redis:latest
ports:
- '6379:6379'
command: redis-server --save 20 1 --loglevel warning --requirepass webbase-redis
volumes:
- cache:/data
volumes:
cache:
driver: local

@ -12,7 +12,7 @@ RUN mkdir -p /application/Core/Configuration /var/www/.gnupg && \
# YAML + dev dependencies + additional packages
RUN apt-get update -y && \
apt-get install -y libyaml-dev libzip-dev libgmp-dev libpng-dev libmagickwand-dev gnupg2 git $ADDITIONAL_PACKAGES && \
printf "\n" | pecl install yaml imagick && docker-php-ext-enable yaml imagick && \
printf "\n" | pecl install yaml imagick redis && docker-php-ext-enable yaml imagick redis && \
docker-php-ext-install gd
# Browscap