Skip to content

Commit 49bf1d6

Browse files
committed
Add server-side captcha to contact shortcodes
1 parent 2bf0e2b commit 49bf1d6

File tree

4 files changed

+214
-5
lines changed

4 files changed

+214
-5
lines changed

controllers/front/contact.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ protected function formProcess()
4141
return $this->terminateWithResponse($this->module->l('Invalid security token.'));
4242
}
4343

44+
if (!\Everblock\Tools\Service\CaptchaService::validateResponse(
45+
Tools::getValue('evercaptcha_token'),
46+
Tools::getValue('evercaptcha_answer')
47+
)) {
48+
return $this->terminateWithResponse(
49+
$this->context->smarty->fetch(_PS_MODULE_DIR_ . '/everblock/views/templates/front/error.tpl')
50+
);
51+
}
52+
4453
// ➕ Récupération du formulaire
4554
$formData = Tools::getAllValues();
4655

@@ -60,7 +69,7 @@ protected function formProcess()
6069

6170
// ➕ Contenu du message HTML
6271
$messageContent = '';
63-
$excludedKeys = ['token', 'everHide', 'submit', 'action'];
72+
$excludedKeys = ['token', 'everHide', 'submit', 'action', 'evercaptcha_token', 'evercaptcha_answer'];
6473

6574
foreach ($formData as $key => $value) {
6675
if (in_array($key, $excludedKeys)) {

models/EverblockTools.php

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
* @copyright 2019-2025 Team Ever
1818
* @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0)
1919
*/
20+
use Everblock\Tools\Service\CaptchaService;
2021
use Everblock\Tools\Service\EverblockCache;
2122

2223
if (!defined('_PS_VERSION_')) {
@@ -1858,11 +1859,15 @@ public static function getFormShortcode(string $txt, Context $context, Everblock
18581859
$txt
18591860
);
18601861

1861-
// Remplace [evercontactform_close] par input token + fermeture du form
1862+
// Remplace [evercontactform_close] par captcha + input token + fermeture du form
18621863
$token = Tools::getToken();
1863-
$txt = str_replace(
1864-
'[evercontactform_close]',
1865-
'<input type="hidden" name="token" value="' . $token . '"></form></div>',
1864+
$txt = preg_replace_callback(
1865+
'/\[evercontactform_close\]/',
1866+
function () use ($context, $module, $token) {
1867+
$captchaHtml = CaptchaService::renderCaptchaField($context, $module);
1868+
1869+
return $captchaHtml . '<input type="hidden" name="token" value="' . $token . '"></form></div>';
1870+
},
18661871
$txt
18671872
);
18681873

src/Service/CaptchaService.php

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
<?php
2+
3+
/**
4+
* 2019-2025 Team Ever
5+
*
6+
* NOTICE OF LICENSE
7+
*
8+
* This source file is subject to the Academic Free License (AFL 3.0)
9+
* that is bundled with this package in the file LICENSE.txt.
10+
* It is also available through the world-wide-web at this URL:
11+
* http://opensource.org/licenses/afl-3.0.php
12+
* If you did not receive a copy of the license and are unable to
13+
* obtain it through the world-wide-web, please send an email
14+
* to [email protected] so we can send you a copy immediately.
15+
*
16+
* @author Team Ever <https://www.team-ever.com/>
17+
* @copyright 2019-2025 Team Ever
18+
* @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0)
19+
*/
20+
21+
namespace Everblock\Tools\Service;
22+
23+
use Configuration;
24+
use Context;
25+
use Exception;
26+
use Tools;
27+
28+
if (!defined('_PS_VERSION_')) {
29+
exit;
30+
}
31+
32+
class CaptchaService
33+
{
34+
private const TOKEN_SEPARATOR = '.';
35+
private const TOKEN_TTL = 900; // 15 minutes
36+
37+
/**
38+
* Render the captcha block for a contact form.
39+
*/
40+
public static function renderCaptchaField(Context $context, \Everblock $module): string
41+
{
42+
try {
43+
$challenge = static::createChallengePayload();
44+
} catch (Exception $e) {
45+
return '';
46+
}
47+
48+
$question = sprintf(
49+
$module->l('What is %d + %d?', 'captchaservice'),
50+
$challenge['a'],
51+
$challenge['b']
52+
);
53+
$fieldId = 'evercaptcha_' . $challenge['nonce'];
54+
$token = static::buildToken($challenge);
55+
56+
$context->smarty->assign([
57+
'captcha' => [
58+
'label' => $module->l('Security question', 'captchaservice'),
59+
'helper' => $module->l('Please answer the security question to validate your request.', 'captchaservice'),
60+
'question' => $question,
61+
'field_id' => $fieldId,
62+
'token' => $token,
63+
],
64+
]);
65+
66+
$templatePath = \EverblockTools::getTemplatePath('hook/contact_captcha.tpl', $module);
67+
68+
return $context->smarty->fetch($templatePath);
69+
}
70+
71+
/**
72+
* Validate the captcha answer received from the form submission.
73+
*/
74+
public static function validateResponse(?string $token, ?string $answer): bool
75+
{
76+
if (!$token || !$answer) {
77+
return false;
78+
}
79+
80+
$payload = static::decodeToken($token);
81+
if (empty($payload)) {
82+
return false;
83+
}
84+
85+
if (!isset($payload['a'], $payload['b'], $payload['ts']) || !is_numeric($payload['a']) || !is_numeric($payload['b'])) {
86+
return false;
87+
}
88+
89+
if (!is_int($payload['ts']) || (time() - $payload['ts']) > self::TOKEN_TTL) {
90+
return false;
91+
}
92+
93+
$expected = (int) ($payload['a'] + $payload['b']);
94+
$cleanAnswer = trim((string) $answer);
95+
96+
if ($cleanAnswer === '' || !ctype_digit($cleanAnswer)) {
97+
return false;
98+
}
99+
100+
return (int) $cleanAnswer === $expected;
101+
}
102+
103+
private static function createChallengePayload(): array
104+
{
105+
$a = random_int(1, 9);
106+
$b = random_int(1, 9);
107+
$nonce = bin2hex(random_bytes(6));
108+
109+
return [
110+
'a' => $a,
111+
'b' => $b,
112+
'nonce' => $nonce,
113+
'ts' => time(),
114+
];
115+
}
116+
117+
private static function buildToken(array $payload): string
118+
{
119+
$json = json_encode($payload);
120+
if ($json === false) {
121+
throw new Exception('Unable to encode captcha payload.');
122+
}
123+
124+
$signature = hash_hmac('sha256', $json, static::getSecret());
125+
126+
return base64_encode($json) . self::TOKEN_SEPARATOR . $signature;
127+
}
128+
129+
private static function decodeToken(string $token): array
130+
{
131+
$parts = explode(self::TOKEN_SEPARATOR, $token, 2);
132+
if (count($parts) !== 2) {
133+
return [];
134+
}
135+
136+
[$encoded, $signature] = $parts;
137+
$json = base64_decode($encoded, true);
138+
if ($json === false) {
139+
return [];
140+
}
141+
142+
$expectedSignature = hash_hmac('sha256', $json, static::getSecret());
143+
if (!hash_equals($expectedSignature, $signature)) {
144+
return [];
145+
}
146+
147+
$data = json_decode($json, true);
148+
if (!is_array($data)) {
149+
return [];
150+
}
151+
152+
$data['ts'] = isset($data['ts']) ? (int) $data['ts'] : 0;
153+
154+
return $data;
155+
}
156+
157+
private static function getSecret(): string
158+
{
159+
if (defined('_COOKIE_KEY_')) {
160+
return (string) _COOKIE_KEY_;
161+
}
162+
163+
$fallback = Configuration::get('PS_SHOP_EMAIL');
164+
165+
return Tools::hash($fallback ?: 'everblock');
166+
}
167+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{*
2+
* 2019-2025 Team Ever
3+
*
4+
* NOTICE OF LICENSE
5+
*
6+
* This source file is subject to the Academic Free License (AFL 3.0)
7+
* that is bundled with this package in the file LICENSE.txt.
8+
* It is also available through the world-wide-web at this URL:
9+
* http://opensource.org/licenses/afl-3.0.php
10+
* If you did not receive a copy of the license and are unable to
11+
* obtain it through the world-wide-web, please send an email
12+
* to [email protected] so we can send you a copy immediately.
13+
*
14+
* @author Team Ever <https://www.team-ever.com/>
15+
* @copyright 2019-2025 Team Ever
16+
* @license http://opensource.org/licenses/afl-3.0.php Academic Free License (AFL 3.0)
17+
*}
18+
{if isset($captcha)}
19+
<div class="form-group mb-4 evercontact-captcha">
20+
<label for="{$captcha.field_id|escape:'htmlall':'UTF-8'}" class="form-label fw-bold d-block">{$captcha.label|escape:'htmlall':'UTF-8'}</label>
21+
<p class="small text-muted">{$captcha.helper|escape:'htmlall':'UTF-8'}</p>
22+
<div class="input-group">
23+
<span class="input-group-text">{$captcha.question|escape:'htmlall':'UTF-8'}</span>
24+
<input type="text" name="evercaptcha_answer" id="{$captcha.field_id|escape:'htmlall':'UTF-8'}" class="form-control" required>
25+
</div>
26+
<input type="hidden" name="evercaptcha_token" value="{$captcha.token|escape:'htmlall':'UTF-8'}">
27+
</div>
28+
{/if}

0 commit comments

Comments
 (0)