hCaptcha Integration

This commit is contained in:
Roman 2024-04-23 14:05:29 +02:00
parent aea20b7a10
commit 51ee723dcb
22 changed files with 275 additions and 145 deletions

@ -16,7 +16,7 @@ class Info extends Request {
$settings = $this->context->getSettings(); $settings = $this->context->getSettings();
$this->result["info"] = [ $this->result["info"] = [
"registrationAllowed" => $settings->isRegistrationAllowed(), "registrationAllowed" => $settings->isRegistrationAllowed(),
"recaptchaEnabled" => $settings->isRecaptchaEnabled(), "captchaEnabled" => $settings->isCaptchaEnabled(),
"version" => WEBBASE_VERSION, "version" => WEBBASE_VERSION,
"siteName" => $settings->getSiteName(), "siteName" => $settings->getSiteName(),
]; ];

@ -3,6 +3,8 @@
namespace Core\API { namespace Core\API {
use Core\API\Parameter\IntegerType; use Core\API\Parameter\IntegerType;
use Core\API\Parameter\StringType;
use Core\Objects\Captcha\CaptchaProvider;
use Core\Objects\Context; use Core\Objects\Context;
use Core\API\Parameter\ArrayType; use Core\API\Parameter\ArrayType;
use Core\API\Parameter\Parameter; use Core\API\Parameter\Parameter;
@ -20,7 +22,7 @@ namespace Core\API {
"allowed_extensions" => new ArrayType("allowed_extensions", Parameter::TYPE_STRING), "allowed_extensions" => new ArrayType("allowed_extensions", Parameter::TYPE_STRING),
"trusted_domains" => new ArrayType("trusted_domains", Parameter::TYPE_STRING), "trusted_domains" => new ArrayType("trusted_domains", Parameter::TYPE_STRING),
"user_registration_enabled" => new Parameter("user_registration_enabled", Parameter::TYPE_BOOLEAN), "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_enabled" => new Parameter("mail_enabled", Parameter::TYPE_BOOLEAN),
"mail_port" => new IntegerType("mail_port", 1, 65535) "mail_port" => new IntegerType("mail_port", 1, 65535)
]; ];

@ -10,28 +10,12 @@ use Core\Objects\Context;
class Stats extends Request { class Stats extends Request {
private bool $mailConfigured;
private bool $recaptchaConfigured;
public function __construct(Context $context, $externalCall = false) { public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall, array()); 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 { public function _execute(): bool {
$settings = $this->context->getSettings();
$sql = $this->context->getSQL(); $sql = $this->context->getSQL();
$userCount = User::count($sql); $userCount = User::count($sql);
$pageCount = Route::count($sql, new CondBool("active")); $pageCount = Route::count($sql, new CondBool("active"));
@ -54,10 +38,6 @@ class Stats extends Request {
$loadAvg = sys_getloadavg(); $loadAvg = sys_getloadavg();
} }
if (!$this->checkSettings()) {
return false;
}
$this->result["data"] = [ $this->result["data"] = [
"userCount" => $userCount, "userCount" => $userCount,
"pageCount" => $pageCount, "pageCount" => $pageCount,
@ -69,8 +49,8 @@ class Stats extends Request {
"memory_usage" => memory_get_usage(), "memory_usage" => memory_get_usage(),
"load_avg" => $loadAvg, "load_avg" => $loadAvg,
"database" => $this->context->getSQL()->getStatus(), "database" => $this->context->getSQL()->getStatus(),
"mail" => $this->mailConfigured, "mail" => $settings->isMailEnabled(),
"reCaptcha" => $this->recaptchaConfigured "captcha" => $settings->getCaptchaProvider()?->jsonSerialize()
], ],
]; ];

@ -727,7 +727,7 @@ namespace Core\API\User {
); );
$settings = $context->getSettings(); $settings = $context->getSettings();
if ($settings->isRecaptchaEnabled()) { if ($settings->isCaptchaEnabled()) {
$parameters["captcha"] = new StringType("captcha"); $parameters["captcha"] = new StringType("captcha");
} }
@ -747,7 +747,7 @@ namespace Core\API\User {
return $this->createError("User Registration is not enabled."); return $this->createError("User Registration is not enabled.");
} }
if ($settings->isRecaptchaEnabled()) { if ($settings->isCaptchaEnabled()) {
$captcha = $this->getParam("captcha"); $captcha = $this->getParam("captcha");
$req = new VerifyCaptcha($this->context); $req = new VerifyCaptcha($this->context);
if (!$req->execute(array("captcha" => $captcha, "action" => "register"))) { if (!$req->execute(array("captcha" => $captcha, "action" => "register"))) {
@ -1003,7 +1003,7 @@ namespace Core\API\User {
); );
$settings = $context->getSettings(); $settings = $context->getSettings();
if ($settings->isRecaptchaEnabled()) { if ($settings->isCaptchaEnabled()) {
$parameters["captcha"] = new StringType("captcha"); $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."); return $this->createError("The mail service is not enabled, please contact the server administration.");
} }
if ($settings->isRecaptchaEnabled()) { if ($settings->isCaptchaEnabled()) {
$captcha = $this->getParam("captcha"); $captcha = $this->getParam("captcha");
$req = new VerifyCaptcha($this->context); $req = new VerifyCaptcha($this->context);
if (!$req->execute(array("captcha" => $captcha, "action" => "resetPassword"))) { if (!$req->execute(array("captcha" => $captcha, "action" => "resetPassword"))) {
@ -1096,7 +1096,7 @@ namespace Core\API\User {
); );
$settings = $context->getSettings(); $settings = $context->getSettings();
if ($settings->isRecaptchaEnabled()) { if ($settings->isCaptchaEnabled()) {
$parameters["captcha"] = new StringType("captcha"); $parameters["captcha"] = new StringType("captcha");
} }
@ -1110,7 +1110,7 @@ namespace Core\API\User {
} }
$settings = $this->context->getSettings(); $settings = $this->context->getSettings();
if ($settings->isRecaptchaEnabled()) { if ($settings->isCaptchaEnabled()) {
$captcha = $this->getParam("captcha"); $captcha = $this->getParam("captcha");
$req = new VerifyCaptcha($this->context); $req = new VerifyCaptcha($this->context);
if (!$req->execute(array("captcha" => $captcha, "action" => "resendConfirmation"))) { if (!$req->execute(array("captcha" => $captcha, "action" => "resendConfirmation"))) {

@ -18,45 +18,15 @@ class VerifyCaptcha extends Request {
public function _execute(): bool { public function _execute(): bool {
$settings = $this->context->getSettings(); $settings = $this->context->getSettings();
if (!$settings->isRecaptchaEnabled()) { $captchaProvider = $settings->getCaptchaProvider();
return $this->createError("Google reCaptcha is not enabled."); if ($captchaProvider === null) {
return $this->createError("No Captcha configured.");
} }
$url = "https://www.google.com/recaptcha/api/siteverify";
$secret = $settings->getRecaptchaSecretKey();
$captcha = $this->getParam("captcha"); $captcha = $this->getParam("captcha");
$action = $this->getParam("action"); $action = $this->getParam("action");
$this->success = $captchaProvider->verify($captcha, $action);
$params = array( $this->lastError = $captchaProvider->getError();
"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");
}
}
}
return $this->success; return $this->success;
} }

@ -12,6 +12,9 @@ use Core\Driver\SQL\Condition\CondNot;
use Core\Driver\SQL\Condition\CondRegex; use Core\Driver\SQL\Condition\CondRegex;
use Core\Driver\SQL\Query\Insert; use Core\Driver\SQL\Query\Insert;
use Core\Driver\SQL\SQL; use Core\Driver\SQL\SQL;
use Core\Objects\Captcha\CaptchaProvider;
use Core\Objects\Captcha\GoogleRecaptchaProvider;
use Core\Objects\Captcha\HCaptchaProvider;
use Core\Objects\Context; use Core\Objects\Context;
class Settings { class Settings {
@ -27,10 +30,10 @@ class Settings {
private array $allowedExtensions; private array $allowedExtensions;
private string $timeZone; private string $timeZone;
// recaptcha // captcha
private bool $recaptchaEnabled; private string $captchaProvider;
private string $recaptchaPublicKey; private string $captchaSiteKey;
private string $recaptchaPrivateKey; private string $captchaSecretKey;
// mail // mail
private bool $mailEnabled; private bool $mailEnabled;
@ -98,10 +101,10 @@ class Settings {
$settings->registrationAllowed = false; $settings->registrationAllowed = false;
$settings->timeZone = date_default_timezone_get(); $settings->timeZone = date_default_timezone_get();
// Recaptcha // captcha
$settings->recaptchaEnabled = false; $settings->captchaProvider = "none";
$settings->recaptchaPublicKey = ""; $settings->captchaSiteKey = "";
$settings->recaptchaPrivateKey = ""; $settings->captchaSecretKey = "";
// Mail // Mail
$settings->mailEnabled = false; $settings->mailEnabled = false;
@ -124,9 +127,9 @@ class Settings {
$this->registrationAllowed = $result["user_registration_enabled"] ?? $this->registrationAllowed; $this->registrationAllowed = $result["user_registration_enabled"] ?? $this->registrationAllowed;
$this->installationComplete = $result["installation_completed"] ?? $this->installationComplete; $this->installationComplete = $result["installation_completed"] ?? $this->installationComplete;
$this->timeZone = $result["time_zone"] ?? $this->timeZone; $this->timeZone = $result["time_zone"] ?? $this->timeZone;
$this->recaptchaEnabled = $result["recaptcha_enabled"] ?? $this->recaptchaEnabled; $this->captchaProvider = $result["captcha_provider"] ?? $this->captchaProvider;
$this->recaptchaPublicKey = $result["recaptcha_public_key"] ?? $this->recaptchaPublicKey; $this->captchaSiteKey = $result["captcha_site_key"] ?? $this->captchaSiteKey;
$this->recaptchaPrivateKey = $result["recaptcha_private_key"] ?? $this->recaptchaPrivateKey; $this->captchaSecretKey = $result["captcha_secret_key"] ?? $this->captchaSecretKey;
$this->mailEnabled = $result["mail_enabled"] ?? $this->mailEnabled; $this->mailEnabled = $result["mail_enabled"] ?? $this->mailEnabled;
$this->mailSender = $result["mail_from"] ?? $this->mailSender; $this->mailSender = $result["mail_from"] ?? $this->mailSender;
$this->mailFooter = $result["mail_footer"] ?? $this->mailFooter; $this->mailFooter = $result["mail_footer"] ?? $this->mailFooter;
@ -146,9 +149,9 @@ class Settings {
->addRow("user_registration_enabled", json_encode($this->registrationAllowed), false, false) ->addRow("user_registration_enabled", json_encode($this->registrationAllowed), false, false)
->addRow("installation_completed", json_encode($this->installationComplete), true, true) ->addRow("installation_completed", json_encode($this->installationComplete), true, true)
->addRow("time_zone", json_encode($this->timeZone), false, false) ->addRow("time_zone", json_encode($this->timeZone), false, false)
->addRow("recaptcha_enabled", json_encode($this->recaptchaEnabled), false, false) ->addRow("captcha_provider", json_encode($this->captchaProvider), false, false)
->addRow("recaptcha_public_key", json_encode($this->recaptchaPublicKey), false, false) ->addRow("captcha_site_key", json_encode($this->captchaSiteKey), false, false)
->addRow("recaptcha_private_key", json_encode($this->recaptchaPrivateKey), true, false) ->addRow("captcha_secret_key", json_encode($this->captchaSecretKey), true, false)
->addRow("allowed_extensions", json_encode($this->allowedExtensions), false, false) ->addRow("allowed_extensions", json_encode($this->allowedExtensions), false, false)
->addRow("mail_host", '""', false, false) ->addRow("mail_host", '""', false, false)
->addRow("mail_port", '587', false, false) ->addRow("mail_port", '587', false, false)
@ -176,16 +179,16 @@ class Settings {
return $this->baseUrl; return $this->baseUrl;
} }
public function isRecaptchaEnabled(): bool { public function isCaptchaEnabled(): bool {
return $this->recaptchaEnabled; return CaptchaProvider::isValid($this->captchaProvider);
} }
public function getRecaptchaSiteKey(): string { public function getCaptchaProvider(): ?CaptchaProvider {
return $this->recaptchaPublicKey; return match ($this->captchaProvider) {
} CaptchaProvider::RECAPTCHA => new GoogleRecaptchaProvider($this->captchaSiteKey, $this->captchaSecretKey),
CaptchaProvider::HCAPTCHA => new HCaptchaProvider($this->captchaSiteKey, $this->captchaSecretKey),
public function getRecaptchaSecretKey(): string { default => null,
return $this->recaptchaPrivateKey; };
} }
public function isRegistrationAllowed(): bool { public function isRegistrationAllowed(): bool {

@ -5,6 +5,8 @@ namespace Core\Elements;
use Core\Configuration\Settings; use Core\Configuration\Settings;
use Core\Driver\Logger\Logger; use Core\Driver\Logger\Logger;
use Core\Driver\SQL\SQL; use Core\Driver\SQL\SQL;
use Core\Objects\Captcha\GoogleRecaptchaProvider;
use Core\Objects\Captcha\HCaptchaProvider;
use Core\Objects\Context; use Core\Objects\Context;
use Core\Objects\Router\DocumentRoute; use Core\Objects\Router\DocumentRoute;
use Core\Objects\Router\Router; use Core\Objects\Router\Router;
@ -78,7 +80,7 @@ abstract class Document {
return $this->router; return $this->router;
} }
public function addCSPWhitelist(string $path) { public function addCSPWhitelist(string $path): void {
$urlParts = parse_url($path); $urlParts = parse_url($path);
if (!$urlParts || !isset($urlParts["host"])) { if (!$urlParts || !isset($urlParts["host"])) {
$this->cspWhitelist[] = getProtocol() . "://" . getCurrentHostName() . $path; $this->cspWhitelist[] = getProtocol() . "://" . getCurrentHostName() . $path;
@ -89,7 +91,23 @@ abstract class Document {
public function sendHeaders(): void { public function sendHeaders(): void {
if ($this->cspEnabled) { 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); $cspWhiteList = implode(" ", $this->cspWhitelist);
$frameSrc = implode(" ", $frameSrc);
$csp = [ $csp = [
"default-src $cspWhiteList 'self'", "default-src $cspWhiteList 'self'",
"object-src 'none'", "object-src 'none'",
@ -98,10 +116,8 @@ abstract class Document {
"img-src 'self' 'unsafe-inline' data: https:;", "img-src 'self' 'unsafe-inline' data: https:;",
"script-src $cspWhiteList 'nonce-$this->cspNonce'", "script-src $cspWhiteList 'nonce-$this->cspNonce'",
"frame-ancestors 'self'", "frame-ancestors 'self'",
"frame-src $frameSrc 'self'",
]; ];
if ($this->getSettings()->isRecaptchaEnabled()) {
$csp[] = "frame-src https://www.google.com/ 'self'";
}
$compiledCSP = implode("; ", $csp); $compiledCSP = implode("; ", $csp);
header("Content-Security-Policy: $compiledCSP;"); header("Content-Security-Policy: $compiledCSP;");

@ -28,7 +28,7 @@ abstract class Head extends View {
protected abstract function initRawFields(): array; protected abstract function initRawFields(): array;
protected abstract function initTitle(): string; protected abstract function initTitle(): string;
protected function init() { protected function init(): void {
$this->keywords = array(); $this->keywords = array();
$this->description = ""; $this->description = "";
$this->baseUrl = ""; $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 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 addJSCode($code) { $this->sources[] = new Script(Script::MIME_TEXT_JAVASCRIPT, "", $code); }
public function loadFontawesome() { public function loadFontawesome(): void {
$this->addCSS(Link::FONTAWESOME); $this->addCSS(Link::FONTAWESOME);
} }
public function loadGoogleRecaptcha($siteKey) { public function loadJQuery(): void {
$this->addJS("https://www.google.com/recaptcha/api.js?render=$siteKey");
}
public function loadJQuery() {
$this->addJS(Script::JQUERY); $this->addJS(Script::JQUERY);
} }
public function loadBootstrap() { public function loadBootstrap(): void {
$this->addCSS(Link::BOOTSTRAP); $this->addCSS(Link::BOOTSTRAP);
$this->addJS(Script::BOOTSTRAP); $this->addJS(Script::BOOTSTRAP);
} }

@ -60,7 +60,6 @@ class HtmlDocument extends Document {
return $code; return $code;
} }
public function getTitle(): string { public function getTitle(): string {
if ($this->head !== null) { if ($this->head !== null) {
return $this->head->getTitle(); return $this->head->getTitle();

@ -80,6 +80,7 @@ class TemplateDocument extends Document {
$session = $context->getSession(); $session = $context->getSession();
$settings = $this->getSettings(); $settings = $this->getSettings();
$language = $context->getLanguage(); $language = $context->getLanguage();
$captchaProvider = $settings->getCaptchaProvider();
$urlParts = parse_url($this->getRouter()->getRequestedUri()); $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))), "lastModified" => date(L('Y-m-d H:i:s'), @filemtime(self::getTemplatePath($name))),
"registrationEnabled" => $settings->isRegistrationAllowed(), "registrationEnabled" => $settings->isRegistrationAllowed(),
"title" => $this->title, "title" => $this->title,
"recaptcha" => [ "captcha" => [
"key" => $settings->isRecaptchaEnabled() ? $settings->getRecaptchaSiteKey() : null, "provider" => $captchaProvider?->getName(),
"enabled" => $settings->isRecaptchaEnabled(), "site_key" => $captchaProvider?->getSiteKey(),
"enabled" => $captchaProvider !== null,
], ],
"csp" => [ "csp" => [
"nonce" => $this->getCSPNonce(), "nonce" => $this->getCSPNonce(),

@ -17,7 +17,7 @@ return [
"value" => "Wert", "value" => "Wert",
"general" => "Allgemein", "general" => "Allgemein",
"mail" => "Mail", "mail" => "Mail",
"recaptcha" => "reCaptcha", "captcha" => "Captcha",
"uncategorized" => "Unkategorisiert", "uncategorized" => "Unkategorisiert",
# general settings # general settings
@ -40,10 +40,10 @@ return [
"mail_address" => "E-Mail Adresse", "mail_address" => "E-Mail Adresse",
"send_test_email" => "Test E-Mail senden", "send_test_email" => "Test E-Mail senden",
# recaptcha # captcha
"recaptcha_enabled" => "Aktiviere Google reCaptcha", "captcha_provider" => "Captcha Anbieter",
"recaptcha_public_key" => "reCaptcha öffentlicher Schlüssel", "captcha_site_key" => "Öffentlicher Captcha Schlüssel",
"recaptcha_private_key" => "reCaptcha privater Schlüssel", "captcha_secret_key" => "Geheimer Captcha Schlüssel",
# dialog # dialog
"fetch_settings_error" => "Fehler beim Holen der Einstellungen", "fetch_settings_error" => "Fehler beim Holen der Einstellungen",

@ -17,7 +17,7 @@ return [
"value" => "Value", "value" => "Value",
"general" => "General", "general" => "General",
"mail" => "Mail", "mail" => "Mail",
"recaptcha" => "reCaptcha", "captcha" => "Captcha",
"uncategorized" => "Uncategorized", "uncategorized" => "Uncategorized",
# general settings # general settings
@ -40,10 +40,10 @@ return [
"mail_address" => "Mail address", "mail_address" => "Mail address",
"send_test_email" => "Send test e-mail", "send_test_email" => "Send test e-mail",
# recaptcha # captcha
"recaptcha_enabled" => "Enable Google reCaptcha", "captcha_provider" => "Captcha Provider",
"recaptcha_public_key" => "reCaptcha Public Key", "captcha_site_key" => "Captcha Site Key",
"recaptcha_private_key" => "reCaptcha Private Key", "captcha_secret_key" => "Secret Captcha Key",
# dialog # dialog
"fetch_settings_error" => "Error fetching settings", "fetch_settings_error" => "Error fetching settings",

@ -0,0 +1,64 @@
<?php
namespace Core\Objects\Captcha;
use Core\Objects\ApiObject;
abstract class CaptchaProvider extends ApiObject {
const NONE = "none";
const RECAPTCHA = "recaptcha";
const HCAPTCHA = "hcaptcha";
const PROVIDERS = [self::NONE, self::RECAPTCHA, self::HCAPTCHA];
private string $siteKey;
private string $secretKey;
protected string $error;
public function __construct(string $siteKey, string $secretKey) {
$this->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;
}
}

@ -0,0 +1,43 @@
<?php
namespace Core\Objects\Captcha;
class GoogleRecaptchaProvider extends CaptchaProvider {
public function __construct(string $siteKey, string $secretKey) {
parent::__construct($siteKey, $secretKey);
}
public function verify(string $captcha, string $action): bool {
$url = "https://www.google.com/recaptcha/api/siteverify";
$success = false;
$response = $this->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;
}
}

@ -0,0 +1,30 @@
<?php
namespace Core\Objects\Captcha;
class HCaptchaProvider extends CaptchaProvider {
public function __construct(string $siteKey, string $secretKey) {
parent::__construct($siteKey, $secretKey);
}
public function verify(string $captcha, string $action): bool {
$success = true;
$url = "https://api.hcaptcha.com/siteverify";
$response = $this->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;
}
}

@ -9,8 +9,15 @@
<link rel="stylesheet" href="/css/fontawesome.min.css" nonce="{{ site.csp.nonce }}"> <link rel="stylesheet" href="/css/fontawesome.min.css" nonce="{{ site.csp.nonce }}">
<link rel="stylesheet" href="/css/account.css" nonce="{{ site.csp.nonce }}"> <link rel="stylesheet" href="/css/account.css" nonce="{{ site.csp.nonce }}">
<title>{{ L("account.title")}} - {{ L(view_title) }}</title> <title>{{ L("account.title")}} - {{ L(view_title) }}</title>
{% if site.recaptcha.enabled %} {% if site.captcha.enabled %}
<script src="https://www.google.com/recaptcha/api.js?render={{ site.recaptcha.key }}" nonce="{{ site.csp.nonce }}"></script> <script nonce="{{ site.csp.nonce }}">
window.captchaProvider = {{ site.captcha|json_encode()|raw }};
</script>
{% if site.captcha.provider == 'recaptcha' %}
<script src="https://www.google.com/recaptcha/api.js?render={{ site.captcha.site_key }}" nonce="{{ site.csp.nonce }}"></script>
{% elseif site.captcha.provider == 'hcaptcha' %}
<script src="https://js.hcaptcha.com/1/api.js" nonce="{{ site.csp.nonce }}"></script>
{% endif %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}
@ -30,7 +37,4 @@
</div> </div>
</div> </div>
</div> </div>
{% if site.recaptcha.enabled %}
<input type='hidden' value='{{ site.recaptcha.key }}' id='siteKey' />
{% endif %}
{% endblock %} {% endblock %}

@ -40,6 +40,9 @@
<input type="password" autocomplete='new-password' name='confirmPassword' <input type="password" autocomplete='new-password' name='confirmPassword'
id='confirmPassword' class="form-control" placeholder="{{ L('account.password_confirm') }}"> id='confirmPassword' class="form-control" placeholder="{{ L('account.password_confirm') }}">
</div> </div>
{% if site.captcha.enabled and site.captcha.provider == 'hcaptcha' %}
<div class="h-captcha mt-2" data-sitekey="{{ site.captcha.site_key }}"></div>
{% endif %}
<div class="input-group mt-3"> <div class="input-group mt-3">
<button type="button" class="btn btn-primary" id='btnRegister'> <button type="button" class="btn btn-primary" id='btnRegister'>
{{ L('general.submit') }} {{ L('general.submit') }}

@ -53,6 +53,9 @@
class="form-control" type="email" maxlength="64" /> class="form-control" type="email" maxlength="64" />
</div> </div>
</div> </div>
{% if site.captcha.enabled and site.captcha.provider == 'hcaptcha' %}
<div class="h-captcha mt-2" data-sitekey="{{ site.captcha.site_key }}"></div>
{% endif %}
<div class="input-group mt-2"> <div class="input-group mt-2">
<button id='btnRequestPasswordReset' class='btn btn-primary'> <button id='btnRequestPasswordReset' class='btn btn-primary'>
{{ L('general.submit') }} {{ L('general.submit') }}

@ -15,7 +15,8 @@ Web-Base is a php framework which provides basic web functionalities and a moder
- [Localization](#localization) - [Localization](#localization)
- [Command Line Interface (CLI)](#cli) - [Command Line Interface (CLI)](#cli)
- [Account & User functions](#access-control) - [Account & User functions](#access-control)
- Admin Dashboard - [Google reCaptcha](https://developers.google.com/recaptcha/) and [hCaptcha](https://docs.hcaptcha.com/) Integration
- modern ReactJS Admin Dashboard
- Docker Support - Docker Support
### Upcoming: ### Upcoming:

@ -14,7 +14,6 @@ $(document).ready(function () {
function hideAlert() { function hideAlert() {
$("#alertMessage").hide(); $("#alertMessage").hide();
} }
function submitForm(btn, method, params, onSuccess) { function submitForm(btn, method, params, onSuccess) {
let textBefore = btn.text(); let textBefore = btn.text();
btn.prop("disabled", true); btn.prop("disabled", true);
@ -27,6 +26,12 @@ $(document).ready(function () {
} else { } else {
onSuccess(); onSuccess();
} }
// reset captcha
let captchaProvider = jsCore.getCaptchaProvider();
if (captchaProvider?.provider === "hcaptcha") {
hcaptcha.reset();
}
}); });
} }
@ -75,11 +80,11 @@ $(document).ready(function () {
} else if(password !== confirmPassword) { } else if(password !== confirmPassword) {
showAlert("danger", L("register.passwords_do_not_match")); showAlert("danger", L("register.passwords_do_not_match"));
} else { } else {
let captchaProvider = jsCore.getCaptchaProvider();
let params = { username: username, email: email, password: password, confirmPassword: confirmPassword }; let params = { username: username, email: email, password: password, confirmPassword: confirmPassword };
if (jsCore.isRecaptchaEnabled()) { if (captchaProvider?.provider === "recaptcha") {
let siteKey = $("#siteKey").val().trim();
grecaptcha.ready(function() { grecaptcha.ready(function() {
grecaptcha.execute(siteKey, {action: 'register'}).then(function(captcha) { grecaptcha.execute(captchaProvider.site_key, {action: 'register'}).then(function(captcha) {
params["captcha"] = captcha; params["captcha"] = captcha;
submitForm(btn, "user/register", params, () => { submitForm(btn, "user/register", params, () => {
showAlert("success", "Account successfully created, check your emails."); showAlert("success", "Account successfully created, check your emails.");
@ -88,6 +93,10 @@ $(document).ready(function () {
}); });
}); });
} else { } else {
if (captchaProvider?.provider === "hcaptcha") {
params.captcha = hcaptcha.getResponse();
}
submitForm(btn, "user/register", params, () => { submitForm(btn, "user/register", params, () => {
showAlert("success", "Account successfully created, check your emails."); showAlert("success", "Account successfully created, check your emails.");
$("input:not([id='siteKey'])").val(""); $("input:not([id='siteKey'])").val("");
@ -132,12 +141,11 @@ $(document).ready(function () {
let btn = $(this); let btn = $(this);
let email = $("#email").val(); let email = $("#email").val();
let captchaProvider = jsCore.getCaptchaProvider();
let params = { email: email }; let params = { email: email };
if (jsCore.isRecaptchaEnabled()) { if (captchaProvider?.provider === "recaptcha") {
let siteKey = $("#siteKey").val().trim();
grecaptcha.ready(function() { grecaptcha.ready(function() {
grecaptcha.execute(siteKey, {action: 'resetPassword'}).then(function(captcha) { grecaptcha.execute(captchaProvider.site_key, {action: 'resetPassword'}).then(function(captcha) {
params["captcha"] = captcha; params["captcha"] = captcha;
submitForm(btn, "user/requestPasswordReset", params, () => { submitForm(btn, "user/requestPasswordReset", params, () => {
showAlert("success", "If the e-mail address exists and is linked to a account, you will receive a password reset token."); showAlert("success", "If the e-mail address exists and is linked to a account, you will receive a password reset token.");
@ -146,6 +154,9 @@ $(document).ready(function () {
}); });
}); });
} else { } else {
if (captchaProvider?.provider === "hcaptcha") {
params.captcha = hcaptcha.getResponse();
}
submitForm(btn, "user/requestPasswordReset", params, () => { submitForm(btn, "user/requestPasswordReset", params, () => {
showAlert("success", "If the e-mail address exists and is linked to a account, you will receive a password reset token."); showAlert("success", "If the e-mail address exists and is linked to a account, you will receive a password reset token.");
$("input:not([id='siteKey'])").val(""); $("input:not([id='siteKey'])").val("");
@ -191,11 +202,11 @@ $(document).ready(function () {
let btn = $(this); let btn = $(this);
let email = $("#email").val(); let email = $("#email").val();
let captchaProvider = jsCore.getCaptchaProvider();
let params = { email: email }; let params = { email: email };
if (jsCore.isRecaptchaEnabled()) { if (captchaProvider?.provider === "recaptcha") {
let siteKey = $("#siteKey").val().trim();
grecaptcha.ready(function() { grecaptcha.ready(function() {
grecaptcha.execute(siteKey, {action: 'resendConfirmation'}).then(function(captcha) { grecaptcha.execute(captchaProvider.site_key, {action: 'resendConfirmation'}).then(function(captcha) {
params["captcha"] = captcha; params["captcha"] = captcha;
submitForm(btn, "user/resendConfirmEmail", params, () => { submitForm(btn, "user/resendConfirmEmail", params, () => {
showAlert("success", "If the e-mail address exists and is linked to a account, you will receive a new confirmation email."); showAlert("success", "If the e-mail address exists and is linked to a account, you will receive a new confirmation email.");
@ -204,6 +215,10 @@ $(document).ready(function () {
}); });
}); });
} else { } else {
if (captchaProvider?.provider === "hcaptcha") {
params.captcha = hcaptcha.getResponse();
}
submitForm(btn, "user/resendConfirmEmail", params, () => { submitForm(btn, "user/resendConfirmEmail", params, () => {
showAlert("success", "\"If the e-mail address exists and is linked to a account, you will receive a new confirmation email."); showAlert("success", "\"If the e-mail address exists and is linked to a account, you will receive a new confirmation email.");
$("input:not([id='siteKey'])").val(""); $("input:not([id='siteKey'])").val("");

@ -162,8 +162,8 @@ let Core = function () {
return this.getJsonDateTime(date).split(' ')[1]; return this.getJsonDateTime(date).split(' ')[1];
}; };
this.isRecaptchaEnabled = function () { this.getCaptchaProvider = function () {
return (typeof grecaptcha !== 'undefined'); return window.captchaProvider || null;
} }
this.__construct(); this.__construct();

@ -18,13 +18,12 @@ import {Link} from "react-router-dom";
import { import {
Add, Add,
Delete, Delete,
Google,
LibraryBooks, LibraryBooks,
Mail, Mail,
RestartAlt, RestartAlt,
Save, Save,
Send, Send,
SettingsApplications SettingsApplications, SmartToy
} from "@mui/icons-material"; } from "@mui/icons-material";
import TIME_ZONES from "shared/time-zones"; import TIME_ZONES from "shared/time-zones";
import ButtonBar from "../../elements/button-bar"; import ButtonBar from "../../elements/button-bar";
@ -63,10 +62,10 @@ export default function SettingsView(props) {
"mail_password", "mail_password",
"mail_async", "mail_async",
], ],
"recaptcha": [ "captcha": [
"recaptcha_enabled", "captcha_provider",
"recaptcha_private_key", "captcha_secret_key",
"recaptcha_public_key", "captcha_site_key",
], ],
"hidden": ["installation_completed", "mail_last_sync"] "hidden": ["installation_completed", "mail_last_sync"]
}; };
@ -280,11 +279,11 @@ export default function SettingsView(props) {
</FormControl> </FormControl>
</FormGroup> </FormGroup>
]; ];
} else if (selectedTab === "recaptcha") { } else if (selectedTab === "captcha") {
return [ return [
renderCheckBox("recaptcha_enabled"), renderSelection("captcha_provider", ["none", "recaptcha", "hcaptcha"]),
renderTextInput("recaptcha_public_key", !parseBool(settings.recaptcha_enabled)), renderTextInput("captcha_site_key", settings.captcha_provider === "none"),
renderPasswordInput("recaptcha_private_key", !parseBool(settings.recaptcha_enabled)), renderPasswordInput("captcha_secret_key", settings.captcha_provider === "none"),
]; ];
} else if (selectedTab === "uncategorized") { } else if (selectedTab === "uncategorized") {
return <TableContainer component={Paper}> return <TableContainer component={Paper}>
@ -364,8 +363,8 @@ export default function SettingsView(props) {
icon={<SettingsApplications />} iconPosition={"start"} /> icon={<SettingsApplications />} iconPosition={"start"} />
<Tab value={"mail"} label={L("settings.mail")} <Tab value={"mail"} label={L("settings.mail")}
icon={<Mail />} iconPosition={"start"} /> icon={<Mail />} iconPosition={"start"} />
<Tab value={"recaptcha"} label={L("settings.recaptcha")} <Tab value={"captcha"} label={L("settings.captcha")}
icon={<Google />} iconPosition={"start"} /> icon={<SmartToy />} iconPosition={"start"} />
<Tab value={"uncategorized"} label={L("settings.uncategorized")} <Tab value={"uncategorized"} label={L("settings.uncategorized")}
icon={<LibraryBooks />} iconPosition={"start"} /> icon={<LibraryBooks />} iconPosition={"start"} />
</Tabs> </Tabs>