Skip to content

Commit 454968e

Browse files
nikosdionclaude
andcommitted
Add reCAPTCHA Invisible and hCaptcha CAPTCHA providers
Introduce a CaptchaFactory to replace hardcoded provider checks, and add two new CAPTCHA implementations: Google reCAPTCHA Invisible v2 and hCaptcha. Each provider is self-contained with its own script tags and server-side verification. The sysconfig UI conditionally shows API key fields based on the selected provider. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 665d938 commit 454968e

11 files changed

Lines changed: 374 additions & 24 deletions

File tree

.env.dist

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,9 +269,31 @@ PANOPTICON_USER_REGISTRATION_ACTIVATION_TRIES=3
269269
# CAPTCHA provider
270270
# The CAPTCHA provider to use on the registration form. Options:
271271
# - altcha: Self-hosted ALTCHA proof-of-work challenge (default)
272+
# - recaptcha_invisible: Google reCAPTCHA Invisible (requires API keys)
273+
# - hcaptcha: hCaptcha (requires API keys)
272274
# - none: No CAPTCHA
273275
PANOPTICON_CAPTCHA_PROVIDER=altcha
274276

277+
# reCAPTCHA Site Key
278+
# The site key from the Google reCAPTCHA admin console.
279+
# Only required when captcha_provider is set to recaptcha_invisible.
280+
PANOPTICON_CAPTCHA_RECAPTCHA_SITE_KEY=
281+
282+
# reCAPTCHA Secret Key
283+
# The secret key from the Google reCAPTCHA admin console.
284+
# Only required when captcha_provider is set to recaptcha_invisible.
285+
PANOPTICON_CAPTCHA_RECAPTCHA_SECRET_KEY=
286+
287+
# hCaptcha Site Key
288+
# The site key from the hCaptcha dashboard.
289+
# Only required when captcha_provider is set to hcaptcha.
290+
PANOPTICON_CAPTCHA_HCAPTCHA_SITE_KEY=
291+
292+
# hCaptcha Secret Key
293+
# The secret key from the hCaptcha dashboard.
294+
# Only required when captcha_provider is set to hcaptcha.
295+
PANOPTICON_CAPTCHA_HCAPTCHA_SECRET_KEY=
296+
275297
# Login with passkey
276298
# Should passkeys be allowed as a login method?
277299
PANOPTICON_PASSKEY_LOGIN=true

ViewTemplates/Sysconfig/default_registration.blade.php

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,10 @@
168168
<div class="col-sm-9">
169169
{{ $this->container->html->select->genericList(
170170
data: [
171-
'altcha' => $this->getLanguage()->text('PANOPTICON_SYSCONFIG_OPT_CAPTCHA_ALTCHA'),
172-
'none' => $this->getLanguage()->text('PANOPTICON_SYSCONFIG_OPT_CAPTCHA_NONE'),
171+
'altcha' => $this->getLanguage()->text('PANOPTICON_SYSCONFIG_OPT_CAPTCHA_ALTCHA'),
172+
'recaptcha_invisible' => $this->getLanguage()->text('PANOPTICON_SYSCONFIG_OPT_CAPTCHA_RECAPTCHA_INVISIBLE'),
173+
'hcaptcha' => $this->getLanguage()->text('PANOPTICON_SYSCONFIG_OPT_CAPTCHA_HCAPTCHA'),
174+
'none' => $this->getLanguage()->text('PANOPTICON_SYSCONFIG_OPT_CAPTCHA_NONE'),
173175
],
174176
name: 'options[captcha_provider]',
175177
attribs: [
@@ -183,5 +185,69 @@
183185
</div>
184186
</div>
185187
</div>
188+
189+
{{-- captcha_recaptcha_site_key --}}
190+
<div class="row mb-3" data-showon='[{"field":"options[user_registration]","values":["admin","self"],"sign":"=","op":""},{"field":"options[captcha_provider]","values":["recaptcha_invisible"],"sign":"=","op":"AND"}]'>
191+
<label for="captcha_recaptcha_site_key" class="col-sm-3 col-form-label">
192+
@lang('PANOPTICON_SYSCONFIG_LBL_FIELD_CAPTCHA_RECAPTCHA_SITE_KEY')
193+
</label>
194+
<div class="col-sm-9">
195+
<input type="text" class="form-control" id="captcha_recaptcha_site_key"
196+
name="options[captcha_recaptcha_site_key]"
197+
value="{{{ $config->get('captcha_recaptcha_site_key', '') }}}"
198+
>
199+
<div class="form-text">
200+
@lang('PANOPTICON_SYSCONFIG_LBL_FIELD_CAPTCHA_RECAPTCHA_SITE_KEY_HELP')
201+
</div>
202+
</div>
203+
</div>
204+
205+
{{-- captcha_recaptcha_secret_key --}}
206+
<div class="row mb-3" data-showon='[{"field":"options[user_registration]","values":["admin","self"],"sign":"=","op":""},{"field":"options[captcha_provider]","values":["recaptcha_invisible"],"sign":"=","op":"AND"}]'>
207+
<label for="captcha_recaptcha_secret_key" class="col-sm-3 col-form-label">
208+
@lang('PANOPTICON_SYSCONFIG_LBL_FIELD_CAPTCHA_RECAPTCHA_SECRET_KEY')
209+
</label>
210+
<div class="col-sm-9">
211+
<input type="text" class="form-control" id="captcha_recaptcha_secret_key"
212+
name="options[captcha_recaptcha_secret_key]"
213+
value="{{{ $config->get('captcha_recaptcha_secret_key', '') }}}"
214+
>
215+
<div class="form-text">
216+
@lang('PANOPTICON_SYSCONFIG_LBL_FIELD_CAPTCHA_RECAPTCHA_SECRET_KEY_HELP')
217+
</div>
218+
</div>
219+
</div>
220+
221+
{{-- captcha_hcaptcha_site_key --}}
222+
<div class="row mb-3" data-showon='[{"field":"options[user_registration]","values":["admin","self"],"sign":"=","op":""},{"field":"options[captcha_provider]","values":["hcaptcha"],"sign":"=","op":"AND"}]'>
223+
<label for="captcha_hcaptcha_site_key" class="col-sm-3 col-form-label">
224+
@lang('PANOPTICON_SYSCONFIG_LBL_FIELD_CAPTCHA_HCAPTCHA_SITE_KEY')
225+
</label>
226+
<div class="col-sm-9">
227+
<input type="text" class="form-control" id="captcha_hcaptcha_site_key"
228+
name="options[captcha_hcaptcha_site_key]"
229+
value="{{{ $config->get('captcha_hcaptcha_site_key', '') }}}"
230+
>
231+
<div class="form-text">
232+
@lang('PANOPTICON_SYSCONFIG_LBL_FIELD_CAPTCHA_HCAPTCHA_SITE_KEY_HELP')
233+
</div>
234+
</div>
235+
</div>
236+
237+
{{-- captcha_hcaptcha_secret_key --}}
238+
<div class="row mb-3" data-showon='[{"field":"options[user_registration]","values":["admin","self"],"sign":"=","op":""},{"field":"options[captcha_provider]","values":["hcaptcha"],"sign":"=","op":"AND"}]'>
239+
<label for="captcha_hcaptcha_secret_key" class="col-sm-3 col-form-label">
240+
@lang('PANOPTICON_SYSCONFIG_LBL_FIELD_CAPTCHA_HCAPTCHA_SECRET_KEY')
241+
</label>
242+
<div class="col-sm-9">
243+
<input type="text" class="form-control" id="captcha_hcaptcha_secret_key"
244+
name="options[captcha_hcaptcha_secret_key]"
245+
value="{{{ $config->get('captcha_hcaptcha_secret_key', '') }}}"
246+
>
247+
<div class="form-text">
248+
@lang('PANOPTICON_SYSCONFIG_LBL_FIELD_CAPTCHA_HCAPTCHA_SECRET_KEY_HELP')
249+
</div>
250+
</div>
251+
</div>
186252
</div>
187253
</div>

ViewTemplates/Users/register.blade.php

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,16 @@
77
88
defined('AKEEBA') || die;
99
10-
use Akeeba\Panopticon\Factory;
11-
use Akeeba\Panopticon\Library\Captcha\AltchaCaptcha;
12-
use Awf\Utils\Template;
10+
use Akeeba\Panopticon\Library\Captcha\CaptchaFactory;
1311
1412
/** @var \Akeeba\Panopticon\View\Users\Html $this */
1513
1614
$container = $this->getContainer();
1715
$captchaProvider = $container->appConfig->get('captcha_provider', 'altcha');
1816
1917
// Generate CAPTCHA challenge
20-
$captchaHtml = '';
21-
22-
if ($captchaProvider === 'altcha')
23-
{
24-
$captcha = new AltchaCaptcha($container);
25-
$captchaHtml = $captcha->renderChallenge();
26-
Template::addJs('media://altcha/altcha.js', $container->application, defer: true);
27-
}
18+
$captcha = CaptchaFactory::make($captchaProvider, $container);
19+
$captchaHtml = $captcha?->renderChallenge() ?? '';
2820
2921
?>
3022

languages/en-GB.ini

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1454,7 +1454,17 @@ PANOPTICON_SYSCONFIG_LBL_FIELD_REGISTRATION_ACTIVATION_TRIES="Maximum activation
14541454
PANOPTICON_SYSCONFIG_LBL_FIELD_CAPTCHA_PROVIDER="CAPTCHA provider"
14551455
PANOPTICON_SYSCONFIG_LBL_FIELD_CAPTCHA_PROVIDER_HELP="The CAPTCHA provider to use on the registration form to prevent automated registrations."
14561456
PANOPTICON_SYSCONFIG_OPT_CAPTCHA_ALTCHA="ALTCHA (self-hosted proof-of-work)"
1457+
PANOPTICON_SYSCONFIG_OPT_CAPTCHA_RECAPTCHA_INVISIBLE="reCAPTCHA Invisible (Google)"
1458+
PANOPTICON_SYSCONFIG_OPT_CAPTCHA_HCAPTCHA="hCaptcha"
14571459
PANOPTICON_SYSCONFIG_OPT_CAPTCHA_NONE="None"
1460+
PANOPTICON_SYSCONFIG_LBL_FIELD_CAPTCHA_RECAPTCHA_SITE_KEY="reCAPTCHA Site Key"
1461+
PANOPTICON_SYSCONFIG_LBL_FIELD_CAPTCHA_RECAPTCHA_SITE_KEY_HELP="Enter the site key from your Google reCAPTCHA admin console. You need an Invisible reCAPTCHA v2 key."
1462+
PANOPTICON_SYSCONFIG_LBL_FIELD_CAPTCHA_RECAPTCHA_SECRET_KEY="reCAPTCHA Secret Key"
1463+
PANOPTICON_SYSCONFIG_LBL_FIELD_CAPTCHA_RECAPTCHA_SECRET_KEY_HELP="Enter the secret key from your Google reCAPTCHA admin console."
1464+
PANOPTICON_SYSCONFIG_LBL_FIELD_CAPTCHA_HCAPTCHA_SITE_KEY="hCaptcha Site Key"
1465+
PANOPTICON_SYSCONFIG_LBL_FIELD_CAPTCHA_HCAPTCHA_SITE_KEY_HELP="Enter the site key from your hCaptcha dashboard."
1466+
PANOPTICON_SYSCONFIG_LBL_FIELD_CAPTCHA_HCAPTCHA_SECRET_KEY="hCaptcha Secret Key"
1467+
PANOPTICON_SYSCONFIG_LBL_FIELD_CAPTCHA_HCAPTCHA_SECRET_KEY_HELP="Enter the secret key from your hCaptcha dashboard."
14581468

14591469
PANOPTICON_SYSCONFIG_LBL_EXTENSIONS_TABLE_CAPTION="Table of known extensions and their automatic update preferences"
14601470
PANOPTICON_SYSCONFIG_LBL_EXTENSIONS_CMS="CMS Type"

src/Application/Trait/DefaultConfigurationTrait.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ public function getDefaultConfiguration(): array
111111
'user_registration_activation_days' => 7,
112112
'user_registration_activation_tries' => 3,
113113
'captcha_provider' => 'altcha',
114+
'captcha_recaptcha_site_key' => '',
115+
'captcha_recaptcha_secret_key' => '',
116+
'captcha_hcaptcha_site_key' => '',
117+
'captcha_hcaptcha_secret_key' => '',
114118
];
115119
}
116120

@@ -144,7 +148,7 @@ public function getConfigurationOptionAutocomplete(string $key, $currentValue):
144148
'dbdriver' => ['mysqli', 'pdomysql'],
145149
'dbhost' => array_filter(['localhost', '127.0.0.1', $this->get('dbhost')]),
146150
'user_registration' => ['disabled', 'admin', 'self'],
147-
'captcha_provider' => ['altcha', 'none'],
151+
'captcha_provider' => ['altcha', 'none', 'recaptcha_invisible', 'hcaptcha'],
148152
'user_registration_activation_days' => [1, 3, 5, 7, 14, 30],
149153
'user_registration_activation_tries' => [1, 3, 5, 10],
150154
'default' => [],
@@ -225,7 +229,7 @@ private function getConfigurationOptionFilterCallback(string $key): callable
225229
'user_registration_activation_days' => fn($x) => $this->validateInteger($x, 7, 1, 90),
226230
'user_registration_activation_tries' => fn($x) => $this->validateInteger($x, 3, 1, 100),
227231
'captcha_provider' => fn($x) => $this->validatePresetValues(
228-
$x, 'altcha', ['altcha', 'none']
232+
$x, 'altcha', ['altcha', 'none', 'recaptcha_invisible', 'hcaptcha']
229233
),
230234
default => fn($x) => $x,
231235
};

src/Controller/Users.php

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
use Akeeba\Panopticon\Controller\Trait\ACLTrait;
1313
use Akeeba\Panopticon\Factory;
14-
use Akeeba\Panopticon\Library\Captcha\AltchaCaptcha;
14+
use Akeeba\Panopticon\Library\Captcha\CaptchaFactory;
1515
use Akeeba\Panopticon\Library\Passkey\Authentication;
1616
use Akeeba\Panopticon\View\Users\Html;
1717
use Awf\Mvc\DataController;
@@ -233,15 +233,11 @@ public function register(): void
233233
{
234234
// Validate CAPTCHA
235235
$captchaProvider = $appConfig->get('captcha_provider', 'altcha');
236+
$captcha = CaptchaFactory::make($captchaProvider, $container);
236237

237-
if ($captchaProvider === 'altcha')
238+
if ($captcha !== null && !$captcha->validateResponse())
238239
{
239-
$captcha = new AltchaCaptcha($container);
240-
241-
if (!$captcha->validateResponse())
242-
{
243-
throw new RuntimeException($lang->text('PANOPTICON_USERS_ERR_CAPTCHA_FAILED'));
244-
}
240+
throw new RuntimeException($lang->text('PANOPTICON_USERS_ERR_CAPTCHA_FAILED'));
245241
}
246242

247243
// Validate passwords match

src/Library/Captcha/AltchaCaptcha.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ public function renderChallenge(): string
5454
$challengeJson = htmlspecialchars(json_encode($challenge), ENT_QUOTES, 'UTF-8');
5555

5656
return <<<HTML
57+
<script src="media/altcha/altcha.js" defer></script>
5758
<altcha-widget challengejson="{$challengeJson}" auto="onsubmit"></altcha-widget>
5859
HTML;
5960
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
/**
3+
* @package panopticon
4+
* @copyright Copyright (c)2023-2026 Nicholas K. Dionysopoulos / Akeeba Ltd
5+
* @license https://www.gnu.org/licenses/agpl-3.0.txt GNU Affero General Public License, version 3 or later
6+
*/
7+
8+
namespace Akeeba\Panopticon\Library\Captcha;
9+
10+
defined('AKEEBA') || die;
11+
12+
use Awf\Container\Container;
13+
14+
class CaptchaFactory
15+
{
16+
/**
17+
* Create a CAPTCHA provider instance based on the provider name.
18+
*
19+
* @param string $provider The provider name (e.g. 'altcha', 'recaptcha_invisible', 'hcaptcha', 'none')
20+
* @param Container $container The application container
21+
*
22+
* @return CaptchaInterface|null The CAPTCHA provider instance, or null for 'none'
23+
*/
24+
public static function make(string $provider, Container $container): ?CaptchaInterface
25+
{
26+
return match ($provider)
27+
{
28+
'altcha' => new AltchaCaptcha($container),
29+
'recaptcha_invisible' => new RecaptchaInvisibleCaptcha($container),
30+
'hcaptcha' => new HCaptchaCaptcha($container),
31+
default => null,
32+
};
33+
}
34+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<?php
2+
/**
3+
* @package panopticon
4+
* @copyright Copyright (c)2023-2026 Nicholas K. Dionysopoulos / Akeeba Ltd
5+
* @license https://www.gnu.org/licenses/agpl-3.0.txt GNU Affero General Public License, version 3 or later
6+
*/
7+
8+
namespace Akeeba\Panopticon\Library\Captcha;
9+
10+
defined('AKEEBA') || die;
11+
12+
use Awf\Container\Container;
13+
use GuzzleHttp\RequestOptions;
14+
15+
/**
16+
* hCaptcha Invisible CAPTCHA implementation.
17+
*
18+
* Uses hCaptcha's invisible mode which triggers automatically on form submit.
19+
* The challenge is verified server-side via hCaptcha's siteverify API.
20+
*/
21+
class HCaptchaCaptcha implements CaptchaInterface
22+
{
23+
private string $siteKey;
24+
25+
private string $secretKey;
26+
27+
public function __construct(
28+
private readonly Container $container
29+
)
30+
{
31+
$this->siteKey = trim((string) $this->container->appConfig->get('captcha_hcaptcha_site_key', ''));
32+
$this->secretKey = trim((string) $this->container->appConfig->get('captcha_hcaptcha_secret_key', ''));
33+
}
34+
35+
public function getName(): string
36+
{
37+
return 'hcaptcha';
38+
}
39+
40+
public function getLabel(): string
41+
{
42+
return 'hCaptcha';
43+
}
44+
45+
public function renderChallenge(): string
46+
{
47+
if (empty($this->siteKey))
48+
{
49+
return '';
50+
}
51+
52+
$siteKeyAttr = htmlspecialchars($this->siteKey, ENT_QUOTES, 'UTF-8');
53+
54+
return <<<HTML
55+
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
56+
<div id="panopticon-hcaptcha-invisible"></div>
57+
<script>
58+
var onHCaptchaSubmit = function(token) {
59+
document.getElementById("panopticon-hcaptcha-invisible").closest("form").submit();
60+
};
61+
document.addEventListener("DOMContentLoaded", function() {
62+
var form = document.getElementById("panopticon-hcaptcha-invisible").closest("form");
63+
if (!form) return;
64+
form.addEventListener("submit", function(e) {
65+
if (document.getElementById("h-captcha-response") && document.getElementById("h-captcha-response").value) return;
66+
e.preventDefault();
67+
hcaptcha.execute();
68+
});
69+
});
70+
</script>
71+
<div class="h-captcha" data-sitekey="{$siteKeyAttr}" data-size="invisible" data-callback="onHCaptchaSubmit"></div>
72+
HTML;
73+
}
74+
75+
public function validateResponse(): bool
76+
{
77+
if (empty($this->secretKey))
78+
{
79+
return false;
80+
}
81+
82+
$input = $this->container->input;
83+
$response = $input->get('h-captcha-response', '', 'raw');
84+
85+
if (empty($response))
86+
{
87+
return false;
88+
}
89+
90+
try
91+
{
92+
$options = $this->container->httpFactory->getDefaultRequestOptions();
93+
$options[RequestOptions::FORM_PARAMS] = [
94+
'secret' => $this->secretKey,
95+
'response' => $response,
96+
'sitekey' => $this->siteKey,
97+
];
98+
99+
$client = $this->container->httpFactory->makeClient(cache: false, singleton: false);
100+
$httpResp = $client->post('https://api.hcaptcha.com/siteverify', $options);
101+
102+
$body = json_decode((string) $httpResp->getBody(), true);
103+
104+
return !empty($body['success']);
105+
}
106+
catch (\Throwable)
107+
{
108+
return false;
109+
}
110+
}
111+
}

0 commit comments

Comments
 (0)