diff --git a/Core/API/Info.class.php b/Core/API/Info.class.php index f4a4483..b7dacb4 100644 --- a/Core/API/Info.class.php +++ b/Core/API/Info.class.php @@ -16,7 +16,7 @@ class Info extends Request { $settings = $this->context->getSettings(); $this->result["info"] = [ "registrationAllowed" => $settings->isRegistrationAllowed(), - "recaptchaEnabled" => $settings->isRecaptchaEnabled(), + "captchaEnabled" => $settings->isCaptchaEnabled(), "version" => WEBBASE_VERSION, "siteName" => $settings->getSiteName(), ]; diff --git a/Core/API/SettingsAPI.class.php b/Core/API/SettingsAPI.class.php index 2cd6dd2..095f5e3 100644 --- a/Core/API/SettingsAPI.class.php +++ b/Core/API/SettingsAPI.class.php @@ -3,6 +3,8 @@ namespace Core\API { use Core\API\Parameter\IntegerType; + use Core\API\Parameter\StringType; + use Core\Objects\Captcha\CaptchaProvider; use Core\Objects\Context; use Core\API\Parameter\ArrayType; use Core\API\Parameter\Parameter; @@ -20,7 +22,7 @@ namespace Core\API { "allowed_extensions" => new ArrayType("allowed_extensions", Parameter::TYPE_STRING), "trusted_domains" => new ArrayType("trusted_domains", Parameter::TYPE_STRING), "user_registration_enabled" => new Parameter("user_registration_enabled", Parameter::TYPE_BOOLEAN), - "recaptcha_enabled" => new Parameter("recaptcha_enabled", Parameter::TYPE_BOOLEAN), + "captcha_provider" => new StringType("captcha_provider", -1, true, "none", CaptchaProvider::PROVIDERS), "mail_enabled" => new Parameter("mail_enabled", Parameter::TYPE_BOOLEAN), "mail_port" => new IntegerType("mail_port", 1, 65535) ]; diff --git a/Core/API/Stats.class.php b/Core/API/Stats.class.php index 1c2ce03..d93567f 100644 --- a/Core/API/Stats.class.php +++ b/Core/API/Stats.class.php @@ -10,28 +10,12 @@ use Core\Objects\Context; class Stats extends Request { - private bool $mailConfigured; - private bool $recaptchaConfigured; - public function __construct(Context $context, $externalCall = false) { parent::__construct($context, $externalCall, array()); } - private function checkSettings(): bool { - $req = new \Core\API\Settings\Get($this->context); - $this->success = $req->execute(array("key" => "^(mail_enabled|recaptcha_enabled)$")); - $this->lastError = $req->getLastError(); - - if ($this->success) { - $settings = $req->getResult()["settings"]; - $this->mailConfigured = $settings["mail_enabled"]; - $this->recaptchaConfigured = $settings["recaptcha_enabled"]; - } - - return $this->success; - } - public function _execute(): bool { + $settings = $this->context->getSettings(); $sql = $this->context->getSQL(); $userCount = User::count($sql); $pageCount = Route::count($sql, new CondBool("active")); @@ -54,10 +38,6 @@ class Stats extends Request { $loadAvg = sys_getloadavg(); } - if (!$this->checkSettings()) { - return false; - } - $this->result["data"] = [ "userCount" => $userCount, "pageCount" => $pageCount, @@ -69,8 +49,8 @@ class Stats extends Request { "memory_usage" => memory_get_usage(), "load_avg" => $loadAvg, "database" => $this->context->getSQL()->getStatus(), - "mail" => $this->mailConfigured, - "reCaptcha" => $this->recaptchaConfigured + "mail" => $settings->isMailEnabled(), + "captcha" => $settings->getCaptchaProvider()?->jsonSerialize() ], ]; diff --git a/Core/API/UserAPI.class.php b/Core/API/UserAPI.class.php index f33553d..c1e1a99 100644 --- a/Core/API/UserAPI.class.php +++ b/Core/API/UserAPI.class.php @@ -727,7 +727,7 @@ namespace Core\API\User { ); $settings = $context->getSettings(); - if ($settings->isRecaptchaEnabled()) { + if ($settings->isCaptchaEnabled()) { $parameters["captcha"] = new StringType("captcha"); } @@ -747,7 +747,7 @@ namespace Core\API\User { return $this->createError("User Registration is not enabled."); } - if ($settings->isRecaptchaEnabled()) { + if ($settings->isCaptchaEnabled()) { $captcha = $this->getParam("captcha"); $req = new VerifyCaptcha($this->context); if (!$req->execute(array("captcha" => $captcha, "action" => "register"))) { @@ -1003,7 +1003,7 @@ namespace Core\API\User { ); $settings = $context->getSettings(); - if ($settings->isRecaptchaEnabled()) { + if ($settings->isCaptchaEnabled()) { $parameters["captcha"] = new StringType("captcha"); } @@ -1021,7 +1021,7 @@ namespace Core\API\User { return $this->createError("The mail service is not enabled, please contact the server administration."); } - if ($settings->isRecaptchaEnabled()) { + if ($settings->isCaptchaEnabled()) { $captcha = $this->getParam("captcha"); $req = new VerifyCaptcha($this->context); if (!$req->execute(array("captcha" => $captcha, "action" => "resetPassword"))) { @@ -1096,7 +1096,7 @@ namespace Core\API\User { ); $settings = $context->getSettings(); - if ($settings->isRecaptchaEnabled()) { + if ($settings->isCaptchaEnabled()) { $parameters["captcha"] = new StringType("captcha"); } @@ -1110,7 +1110,7 @@ namespace Core\API\User { } $settings = $this->context->getSettings(); - if ($settings->isRecaptchaEnabled()) { + if ($settings->isCaptchaEnabled()) { $captcha = $this->getParam("captcha"); $req = new VerifyCaptcha($this->context); if (!$req->execute(array("captcha" => $captcha, "action" => "resendConfirmation"))) { diff --git a/Core/API/VerifyCaptcha.class.php b/Core/API/VerifyCaptcha.class.php index 705f370..2febab0 100644 --- a/Core/API/VerifyCaptcha.class.php +++ b/Core/API/VerifyCaptcha.class.php @@ -18,45 +18,15 @@ class VerifyCaptcha extends Request { public function _execute(): bool { $settings = $this->context->getSettings(); - if (!$settings->isRecaptchaEnabled()) { - return $this->createError("Google reCaptcha is not enabled."); + $captchaProvider = $settings->getCaptchaProvider(); + if ($captchaProvider === null) { + return $this->createError("No Captcha configured."); } - $url = "https://www.google.com/recaptcha/api/siteverify"; - $secret = $settings->getRecaptchaSecretKey(); $captcha = $this->getParam("captcha"); $action = $this->getParam("action"); - - $params = array( - "secret" => $secret, - "response" => $captcha - ); - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_POST, 1); - curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params)); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - $response = @json_decode(curl_exec($ch), true); - curl_close($ch); - - $this->success = false; - $this->lastError = "Could not verify captcha: No response from google received."; - - if ($response) { - $this->success = $response["success"]; - if (!$this->success) { - $this->lastError = "Could not verify captcha: " . implode(";", $response["error-codes"]); - } else { - $score = $response["score"]; - if ($action !== $response["action"]) { - $this->createError("Could not verify captcha: Action does not match"); - } else if ($score < 0.7) { - $this->createError("Could not verify captcha: Google ReCaptcha Score < 0.7 (Your score: $score), you are likely a bot"); - } - } - } - + $this->success = $captchaProvider->verify($captcha, $action); + $this->lastError = $captchaProvider->getError(); return $this->success; } diff --git a/Core/Configuration/Settings.class.php b/Core/Configuration/Settings.class.php index 85299dc..ab1cdc5 100644 --- a/Core/Configuration/Settings.class.php +++ b/Core/Configuration/Settings.class.php @@ -12,6 +12,9 @@ use Core\Driver\SQL\Condition\CondNot; use Core\Driver\SQL\Condition\CondRegex; use Core\Driver\SQL\Query\Insert; use Core\Driver\SQL\SQL; +use Core\Objects\Captcha\CaptchaProvider; +use Core\Objects\Captcha\GoogleRecaptchaProvider; +use Core\Objects\Captcha\HCaptchaProvider; use Core\Objects\Context; class Settings { @@ -27,10 +30,10 @@ class Settings { private array $allowedExtensions; private string $timeZone; - // recaptcha - private bool $recaptchaEnabled; - private string $recaptchaPublicKey; - private string $recaptchaPrivateKey; + // captcha + private string $captchaProvider; + private string $captchaSiteKey; + private string $captchaSecretKey; // mail private bool $mailEnabled; @@ -98,10 +101,10 @@ class Settings { $settings->registrationAllowed = false; $settings->timeZone = date_default_timezone_get(); - // Recaptcha - $settings->recaptchaEnabled = false; - $settings->recaptchaPublicKey = ""; - $settings->recaptchaPrivateKey = ""; + // captcha + $settings->captchaProvider = "none"; + $settings->captchaSiteKey = ""; + $settings->captchaSecretKey = ""; // Mail $settings->mailEnabled = false; @@ -124,9 +127,9 @@ class Settings { $this->registrationAllowed = $result["user_registration_enabled"] ?? $this->registrationAllowed; $this->installationComplete = $result["installation_completed"] ?? $this->installationComplete; $this->timeZone = $result["time_zone"] ?? $this->timeZone; - $this->recaptchaEnabled = $result["recaptcha_enabled"] ?? $this->recaptchaEnabled; - $this->recaptchaPublicKey = $result["recaptcha_public_key"] ?? $this->recaptchaPublicKey; - $this->recaptchaPrivateKey = $result["recaptcha_private_key"] ?? $this->recaptchaPrivateKey; + $this->captchaProvider = $result["captcha_provider"] ?? $this->captchaProvider; + $this->captchaSiteKey = $result["captcha_site_key"] ?? $this->captchaSiteKey; + $this->captchaSecretKey = $result["captcha_secret_key"] ?? $this->captchaSecretKey; $this->mailEnabled = $result["mail_enabled"] ?? $this->mailEnabled; $this->mailSender = $result["mail_from"] ?? $this->mailSender; $this->mailFooter = $result["mail_footer"] ?? $this->mailFooter; @@ -146,9 +149,9 @@ class Settings { ->addRow("user_registration_enabled", json_encode($this->registrationAllowed), false, false) ->addRow("installation_completed", json_encode($this->installationComplete), true, true) ->addRow("time_zone", json_encode($this->timeZone), false, false) - ->addRow("recaptcha_enabled", json_encode($this->recaptchaEnabled), false, false) - ->addRow("recaptcha_public_key", json_encode($this->recaptchaPublicKey), false, false) - ->addRow("recaptcha_private_key", json_encode($this->recaptchaPrivateKey), true, false) + ->addRow("captcha_provider", json_encode($this->captchaProvider), false, false) + ->addRow("captcha_site_key", json_encode($this->captchaSiteKey), false, false) + ->addRow("captcha_secret_key", json_encode($this->captchaSecretKey), true, false) ->addRow("allowed_extensions", json_encode($this->allowedExtensions), false, false) ->addRow("mail_host", '""', false, false) ->addRow("mail_port", '587', false, false) @@ -176,16 +179,16 @@ class Settings { return $this->baseUrl; } - public function isRecaptchaEnabled(): bool { - return $this->recaptchaEnabled; + public function isCaptchaEnabled(): bool { + return CaptchaProvider::isValid($this->captchaProvider); } - public function getRecaptchaSiteKey(): string { - return $this->recaptchaPublicKey; - } - - public function getRecaptchaSecretKey(): string { - return $this->recaptchaPrivateKey; + public function getCaptchaProvider(): ?CaptchaProvider { + return match ($this->captchaProvider) { + CaptchaProvider::RECAPTCHA => new GoogleRecaptchaProvider($this->captchaSiteKey, $this->captchaSecretKey), + CaptchaProvider::HCAPTCHA => new HCaptchaProvider($this->captchaSiteKey, $this->captchaSecretKey), + default => null, + }; } public function isRegistrationAllowed(): bool { diff --git a/Core/Elements/Document.class.php b/Core/Elements/Document.class.php index 4e2e38e..4f4abfc 100644 --- a/Core/Elements/Document.class.php +++ b/Core/Elements/Document.class.php @@ -5,6 +5,8 @@ namespace Core\Elements; use Core\Configuration\Settings; use Core\Driver\Logger\Logger; use Core\Driver\SQL\SQL; +use Core\Objects\Captcha\GoogleRecaptchaProvider; +use Core\Objects\Captcha\HCaptchaProvider; use Core\Objects\Context; use Core\Objects\Router\DocumentRoute; use Core\Objects\Router\Router; @@ -78,7 +80,7 @@ abstract class Document { return $this->router; } - public function addCSPWhitelist(string $path) { + public function addCSPWhitelist(string $path): void { $urlParts = parse_url($path); if (!$urlParts || !isset($urlParts["host"])) { $this->cspWhitelist[] = getProtocol() . "://" . getCurrentHostName() . $path; @@ -89,7 +91,23 @@ abstract class Document { public function sendHeaders(): void { if ($this->cspEnabled) { + $frameSrc = []; + + $captchaProvider = $this->getSettings()->getCaptchaProvider(); + if ($captchaProvider instanceof GoogleRecaptchaProvider) { + $frameSrc[] = "https://www.google.com/recaptcha/"; + $frameSrc[] = "https://recaptcha.google.com/recaptcha/"; + $this->cspWhitelist[] = "https://www.google.com/recaptcha/"; + $this->cspWhitelist[] = "https://www.gstatic.com/recaptcha/"; + } else if ($captchaProvider instanceof HCaptchaProvider) { + $frameSrc[] = "https://hcaptcha.com"; + $frameSrc[] = "https://*.hcaptcha.com"; + $this->cspWhitelist[] = "https://hcaptcha.com"; + $this->cspWhitelist[] = "https://*.hcaptcha.com"; + } + $cspWhiteList = implode(" ", $this->cspWhitelist); + $frameSrc = implode(" ", $frameSrc); $csp = [ "default-src $cspWhiteList 'self'", "object-src 'none'", @@ -98,10 +116,8 @@ abstract class Document { "img-src 'self' 'unsafe-inline' data: https:;", "script-src $cspWhiteList 'nonce-$this->cspNonce'", "frame-ancestors 'self'", + "frame-src $frameSrc 'self'", ]; - if ($this->getSettings()->isRecaptchaEnabled()) { - $csp[] = "frame-src https://www.google.com/ 'self'"; - } $compiledCSP = implode("; ", $csp); header("Content-Security-Policy: $compiledCSP;"); diff --git a/Core/Elements/Head.class.php b/Core/Elements/Head.class.php index cbb8a08..6c54ec2 100644 --- a/Core/Elements/Head.class.php +++ b/Core/Elements/Head.class.php @@ -28,7 +28,7 @@ abstract class Head extends View { protected abstract function initRawFields(): array; protected abstract function initTitle(): string; - protected function init() { + protected function init(): void { $this->keywords = array(); $this->description = ""; $this->baseUrl = ""; @@ -51,19 +51,15 @@ abstract class Head extends View { public function addJS($url) { $this->sources[] = new Script(Script::MIME_TEXT_JAVASCRIPT, $url, ""); } public function addJSCode($code) { $this->sources[] = new Script(Script::MIME_TEXT_JAVASCRIPT, "", $code); } - public function loadFontawesome() { + public function loadFontawesome(): void { $this->addCSS(Link::FONTAWESOME); } - public function loadGoogleRecaptcha($siteKey) { - $this->addJS("https://www.google.com/recaptcha/api.js?render=$siteKey"); - } - - public function loadJQuery() { + public function loadJQuery(): void { $this->addJS(Script::JQUERY); } - public function loadBootstrap() { + public function loadBootstrap(): void { $this->addCSS(Link::BOOTSTRAP); $this->addJS(Script::BOOTSTRAP); } diff --git a/Core/Elements/HtmlDocument.class.php b/Core/Elements/HtmlDocument.class.php index e6e69c6..cc88c55 100644 --- a/Core/Elements/HtmlDocument.class.php +++ b/Core/Elements/HtmlDocument.class.php @@ -60,7 +60,6 @@ class HtmlDocument extends Document { return $code; } - public function getTitle(): string { if ($this->head !== null) { return $this->head->getTitle(); diff --git a/Core/Elements/TemplateDocument.class.php b/Core/Elements/TemplateDocument.class.php index 17d71e4..5e7b78f 100644 --- a/Core/Elements/TemplateDocument.class.php +++ b/Core/Elements/TemplateDocument.class.php @@ -80,6 +80,7 @@ class TemplateDocument extends Document { $session = $context->getSession(); $settings = $this->getSettings(); $language = $context->getLanguage(); + $captchaProvider = $settings->getCaptchaProvider(); $urlParts = parse_url($this->getRouter()->getRequestedUri()); @@ -102,9 +103,10 @@ class TemplateDocument extends Document { "lastModified" => date(L('Y-m-d H:i:s'), @filemtime(self::getTemplatePath($name))), "registrationEnabled" => $settings->isRegistrationAllowed(), "title" => $this->title, - "recaptcha" => [ - "key" => $settings->isRecaptchaEnabled() ? $settings->getRecaptchaSiteKey() : null, - "enabled" => $settings->isRecaptchaEnabled(), + "captcha" => [ + "provider" => $captchaProvider?->getName(), + "site_key" => $captchaProvider?->getSiteKey(), + "enabled" => $captchaProvider !== null, ], "csp" => [ "nonce" => $this->getCSPNonce(), diff --git a/Core/Localization/de_DE/settings.php b/Core/Localization/de_DE/settings.php index 398ce2f..aebb4d9 100644 --- a/Core/Localization/de_DE/settings.php +++ b/Core/Localization/de_DE/settings.php @@ -17,7 +17,7 @@ return [ "value" => "Wert", "general" => "Allgemein", "mail" => "Mail", - "recaptcha" => "reCaptcha", + "captcha" => "Captcha", "uncategorized" => "Unkategorisiert", # general settings @@ -40,10 +40,10 @@ return [ "mail_address" => "E-Mail Adresse", "send_test_email" => "Test E-Mail senden", - # recaptcha - "recaptcha_enabled" => "Aktiviere Google reCaptcha", - "recaptcha_public_key" => "reCaptcha öffentlicher Schlüssel", - "recaptcha_private_key" => "reCaptcha privater Schlüssel", + # captcha + "captcha_provider" => "Captcha Anbieter", + "captcha_site_key" => "Öffentlicher Captcha Schlüssel", + "captcha_secret_key" => "Geheimer Captcha Schlüssel", # dialog "fetch_settings_error" => "Fehler beim Holen der Einstellungen", diff --git a/Core/Localization/en_US/settings.php b/Core/Localization/en_US/settings.php index 3375c2f..74743d5 100644 --- a/Core/Localization/en_US/settings.php +++ b/Core/Localization/en_US/settings.php @@ -17,7 +17,7 @@ return [ "value" => "Value", "general" => "General", "mail" => "Mail", - "recaptcha" => "reCaptcha", + "captcha" => "Captcha", "uncategorized" => "Uncategorized", # general settings @@ -40,10 +40,10 @@ return [ "mail_address" => "Mail address", "send_test_email" => "Send test e-mail", - # recaptcha - "recaptcha_enabled" => "Enable Google reCaptcha", - "recaptcha_public_key" => "reCaptcha Public Key", - "recaptcha_private_key" => "reCaptcha Private Key", + # captcha + "captcha_provider" => "Captcha Provider", + "captcha_site_key" => "Captcha Site Key", + "captcha_secret_key" => "Secret Captcha Key", # dialog "fetch_settings_error" => "Error fetching settings", diff --git a/Core/Objects/Captcha/CaptchaProvider.class.php b/Core/Objects/Captcha/CaptchaProvider.class.php new file mode 100644 index 0000000..49be622 --- /dev/null +++ b/Core/Objects/Captcha/CaptchaProvider.class.php @@ -0,0 +1,64 @@ +siteKey = $siteKey; + $this->secretKey = $secretKey; + $this->error = ""; + } + + public function getSiteKey(): string { + return $this->siteKey; + } + + public function getError(): string { + return $this->error; + } + + public static function isValid(string $type): bool { + return in_array($type, [self::RECAPTCHA, self::HCAPTCHA]); + } + + public abstract function verify(string $captcha, string $action): bool; + + public abstract function getName(): string; + + public function jsonSerialize(): array { + return [ + "name" => $this->getName(), + "siteKey" => $this->getSiteKey(), + ]; + } + + protected function performVerifyRequest(string $url, string $captcha) { + $params = [ + "secret" => $this->secretKey, + "response" => $captcha + ]; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_POST, 1); + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params)); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + $response = @json_decode(curl_exec($ch), true); + curl_close($ch); + + return $response; + } +} \ No newline at end of file diff --git a/Core/Objects/Captcha/GoogleRecaptchaProvider.php b/Core/Objects/Captcha/GoogleRecaptchaProvider.php new file mode 100644 index 0000000..d7d9c46 --- /dev/null +++ b/Core/Objects/Captcha/GoogleRecaptchaProvider.php @@ -0,0 +1,43 @@ +performVerifyRequest($url, $captcha); + $this->error = "Could not verify captcha: Invalid response from Google received."; + + if ($response) { + $success = $response["success"]; + if (!$success) { + $this->error = "Could not verify captcha: " . implode(";", $response["error-codes"]); + } else { + $score = $response["score"]; + if ($action !== $response["action"]) { + $success = false; + $this->error = "Could not verify captcha: Action does not match"; + } else if ($score < 0.7) { + $success = false; + $this->error = "Could not verify captcha: Google ReCaptcha Score < 0.7 (Your score: $score), you are likely a bot"; + } else { + $success = true; + $this->error = ""; + } + } + } + + return $success; + } + + public function getName(): string { + return CaptchaProvider::RECAPTCHA; + } +} \ No newline at end of file diff --git a/Core/Objects/Captcha/HCaptchaProvider.class.php b/Core/Objects/Captcha/HCaptchaProvider.class.php new file mode 100644 index 0000000..eba3553 --- /dev/null +++ b/Core/Objects/Captcha/HCaptchaProvider.class.php @@ -0,0 +1,30 @@ +performVerifyRequest($url, $captcha); + $this->error = "Could not verify captcha: Invalid response from hCaptcha received."; + + if ($response) { + $success = $response["success"]; + if (!$success) { + $this->error = "Captcha verification failed."; + } + } + + return $success; + } + + public function getName(): string { + return CaptchaProvider::HCAPTCHA; + } +} \ No newline at end of file diff --git a/Core/Templates/account/account_base.twig b/Core/Templates/account/account_base.twig index 34368c2..a57f667 100644 --- a/Core/Templates/account/account_base.twig +++ b/Core/Templates/account/account_base.twig @@ -9,8 +9,15 @@