Skip to content

Commit 0fa2e5d

Browse files
authoredFeb 26, 2024
Supporting different captcha providers and hCaptcha support (#9)
Supporting different captcha providers hCaptcha provider --------- Co-authored-by: Aleks S <avshelestov@gmail.com>
·
0.3.00.2.2
1 parent 8185346 commit 0fa2e5d

File tree

15 files changed

+292
-84
lines changed

15 files changed

+292
-84
lines changed
 

‎CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
0.2.2
2+
==
3+
26 Frb 2024
4+
5+
**Added:**
6+
7+
* Supporting different captcha providers
8+
* hCaptcha provider
9+
10+
**Migration guide:**
11+
* Create and run BD migrations to apply new changes (see on https://phpform.dev)
12+
* Check forms and update captcha settings if needed

‎README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ developers and businesses looking for a reliable and GDPR-compliant form managem
1313
- **Cost-Effective**: Designed to run smoothly on inexpensive hosting or free cloud services, reducing your operational costs.
1414
- **GDPR Compliant**: We prioritize your data privacy. PHPForm ensures that all your data remains yours, complying fully with GDPR regulations.
1515
- **Browser Push and Email notifications**: Get notified when a new form submission is received.
16-
- **reCaptcha Protection**: Protect your forms from spam and abuse with Google reCaptcha.
16+
- **Captcha Protection**: Protect your forms from spam and abuse with different Captcha providers.
1717
- **Token-based Protection**: Ideal protection for mobile and desktop apps.
1818

1919
# Requirements
@@ -49,7 +49,7 @@ PHPForm is released under MIT, ensuring it remains free and open for use and mod
4949
## Running locally with docker
5050
Use the latest [image from docker hub](https://hub.docker.com/r/phpform/phpform-server) to run it locally:
5151
```bash
52-
docker run --name phpform -d -p 9000:9000 phpform/phpform-server:0.2
52+
docker run --name phpform -d -p 9000:9000 phpform/phpform-server:latest
5353
```
5454
Copy environment file and adjust it to your needs:
5555
```bash

‎src/Admin/Controller/FormController.php

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
<?php
22
namespace App\Admin\Controller;
33

4-
use App\Admin\Form\FormRecaptchaType;
4+
use App\Admin\Form\FormCaptchaType;
55
use App\Admin\Form\FormSecretType;
6+
use App\Captcha\Captcha;
7+
use App\Captcha\CaptchaProviderInterface;
68
use App\Entity\Form;
79
use App\Admin\Form\FormType;
810
use App\Service\FormMenuCounterService;
@@ -20,6 +22,7 @@ public function __construct(
2022
private readonly FormService $formService,
2123
private readonly FormMenuCounterService $formMenuCounterService,
2224
private readonly FormSubmissionService $formSubmissionService,
25+
private readonly Captcha $captcha,
2326
)
2427
{
2528
}
@@ -75,24 +78,31 @@ public function edit(Request $request, Form $formEntity): Response
7578
]);
7679
}
7780

78-
#[Route('/admin/forms/{id}/recaptcha', name: 'admin_forms_recaptcha')]
79-
public function recaptcha(Request $request, Form $formEntity): Response
81+
#[Route('/admin/forms/{id}/captcha', name: 'admin_forms_captcha')]
82+
public function captcha(Request $request, Form $formEntity): Response
8083
{
81-
$form = $this->createForm(FormRecaptchaType::class, $formEntity);
84+
$form = $this->createForm(FormCaptchaType::class, $formEntity);
8285
$form->handleRequest($request);
8386

8487
if ($form->isSubmitted() && $form->isValid()) {
8588
$this->formService->edit($formEntity);
8689

87-
$this->addFlash('primary', 'Recaptcha Secret Key updated successfully');
90+
$this->addFlash('primary', 'Captcha Secret Key updated successfully');
8891

89-
return $this->redirectToRoute('admin_forms_recaptcha', ['id' => $formEntity->getId()]);
92+
return $this->redirectToRoute('admin_forms_captcha', ['id' => $formEntity->getId()]);
9093
}
9194

92-
return $this->render('@Admin/forms/recaptcha.html.twig', [
95+
return $this->render('@Admin/forms/captcha.html.twig', [
9396
'form' => $form->createView(),
9497
'formEntity' => $formEntity,
9598
'menuCounts' => $this->formMenuCounterService->getAllCountsByFormId($formEntity->getId()),
99+
'providersInfo' => array_map(static function(CaptchaProviderInterface $provider) {
100+
return [
101+
'name' => $provider->getName(),
102+
'homepageUrl' => $provider->getHomePageUrl(),
103+
'documentationUrl' => $provider->getDocumentationUrl(),
104+
];
105+
}, $this->captcha->getProviders())
96106
]);
97107
}
98108

‎src/Admin/Form/FormRecaptchaType.php renamed to ‎src/Admin/Form/FormCaptchaType.php

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,27 @@
11
<?php
22
namespace App\Admin\Form;
33

4+
use App\Captcha\Captcha;
5+
use App\Captcha\CaptchaProviderInterface;
46
use App\Entity\Form;
57
use Symfony\Component\Form\AbstractType;
68
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
79
use Symfony\Component\Form\FormBuilderInterface;
810
use Symfony\Component\OptionsResolver\OptionsResolver;
11+
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
912

10-
class FormRecaptchaType extends AbstractType
13+
class FormCaptchaType extends AbstractType
1114
{
1215
public function buildForm(FormBuilderInterface $builder, array $options): void
1316
{
1417
$builder
15-
->add('recaptcha_token', null, ['label' => 'reCAPTCHA Secret Key'])
18+
->add('captcha_provider', ChoiceType::class, [
19+
'label' => 'Captcha Provider',
20+
'choices' => array_flip(array_map(static function(CaptchaProviderInterface $provider){
21+
return $provider->getName();
22+
}, (new Captcha())->getProviders())),
23+
])
24+
->add('captcha_token', null, ['label' => 'Secret Key'])
1625
->add('save', SubmitType::class, ['label' => 'Save']);
1726
}
1827

‎src/Admin/Resources/templates/bulma_theme.html.twig

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
{% block form_row %}
22
<div class="field">
3-
{{ form_label(form) }}
4-
<div class="control">
3+
<label class="has-text-weight-bold">
4+
{{ form_label(form) }}
5+
</label>
6+
<div class="control mt-1">
57
{{ form_widget(form) }}
68
</div>
79
{{ form_errors(form) }}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
{% extends '@Admin/forms/page.html.twig' %}
2+
3+
{% block title %}API / {{ formEntity.name }} / Forms{% endblock %}
4+
5+
{% block section %}
6+
<div class="card">
7+
<header class="card-header">
8+
<p class="card-header-title">
9+
Protect form with captcha
10+
</p>
11+
</header>
12+
<div class="card-content" x-data="providersData()">
13+
<div class="content">
14+
<form method="post">
15+
{{ form_start(form) }}
16+
<p>
17+
Generate your Secret Key at <a target="_blank" :href="currentProvider.homepageUrl"><span x-html="currentProvider.name"></span></a>.
18+
Only the Secret Key is required here.
19+
Ensure to implement token retrieval on the frontend and include it in the PHPForm endpoint request under the 'captchaResponse' key.
20+
</p>
21+
{{ form_row(form.captcha_provider, {
22+
'attr': {'@change': 'change($event.target.value)'}
23+
}) }}
24+
{{ form_row(form.captcha_token) }}
25+
<p>
26+
Keep the field empty to disable Captcha Protection.
27+
</p>
28+
<p>
29+
Example of how to include the token in the request:
30+
</p>
31+
<div class="mb-4">
32+
<pre style="overflow:auto; width:100%; max-width: 550px;"><code>fetch('https://your-domain.com/api/forms/{{ formEntity.hash }}', {
33+
method: 'POST',
34+
headers: {
35+
'Content-Type': 'application/json',
36+
},
37+
body: JSON.stringify({
38+
captchaResponse: 'captcha-token-here',
39+
// other form fields
40+
}),
41+
})</code></pre>
42+
</div>
43+
<p>
44+
More information on how to use it can be found <a target="_blank" :href="currentProvider.documentationUrl">here</a>.
45+
</p>
46+
{{ form_row(form.save) }}
47+
{{ form_end(form) }}
48+
</form>
49+
</div>
50+
</div>
51+
</div>
52+
53+
<script>
54+
function providersData() {
55+
const providers = {{ providersInfo|json_encode|raw }};
56+
return {
57+
providers,
58+
currentProvider: providers[0],
59+
change: function (value) {
60+
this.currentProvider = providers[value];
61+
}
62+
};
63+
}
64+
</script>
65+
{% endblock %}

‎src/Admin/Resources/templates/forms/index.html.twig

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,8 @@
6767
</a>
6868
{% endif %}
6969
{% if app.user.isSuperUser %}
70-
{% if (form.recaptchaToken|length == 0 and form.secret|length == 0) %}
71-
<a class="tag is-danger is-light ml-1" title="Please use token or reCaptcha protection" href="{{ path('admin_forms_token', { id: form.id }) }}">
70+
{% if (form.captchaToken|length == 0 and form.secret|length == 0) %}
71+
<a class="tag is-danger is-light ml-1" title="Please use token or captcha protection" href="{{ path('admin_forms_token', { id: form.id }) }}">
7272
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" style="width: 16px; height: 16px; fill: hsl(348, 100%, 61%);">
7373
<path d="M21.171,15.398l-5.912-9.854C14.483,4.251,13.296,3.511,12,3.511s-2.483,0.74-3.259,2.031l-5.912,9.856 c-0.786,1.309-0.872,2.705-0.235,3.83C3.23,20.354,4.472,21,6,21h12c1.528,0,2.77-0.646,3.406-1.771 C22.043,18.104,21.957,16.708,21.171,15.398z M12,17.549c-0.854,0-1.55-0.695-1.55-1.549c0-0.855,0.695-1.551,1.55-1.551 s1.55,0.696,1.55,1.551C13.55,16.854,12.854,17.549,12,17.549z M13.633,10.125c-0.011,0.031-1.401,3.468-1.401,3.468 c-0.038,0.094-0.13,0.156-0.231,0.156s-0.193-0.062-0.231-0.156l-1.391-3.438C10.289,9.922,10.25,9.712,10.25,9.5 c0-0.965,0.785-1.75,1.75-1.75s1.75,0.785,1.75,1.75C13.75,9.712,13.711,9.922,13.633,10.125z"/>
7474
</svg>

‎src/Admin/Resources/templates/forms/page.html.twig

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,12 @@
8282
</li>
8383
<li>
8484
<a
85-
{% if current_path == 'admin_forms_recaptcha' %}
85+
{% if current_path == 'admin_forms_captcha' %}
8686
class="is-active"
8787
{% endif %}
88-
href="{{ path('admin_forms_recaptcha', {'id': formEntity.id}) }}"
88+
href="{{ path('admin_forms_captcha', {'id': formEntity.id}) }}"
8989
>
90-
reCAPTCHA Protection
90+
Captcha Protection
9191
</a>
9292
</li>
9393
<li>

‎src/Admin/Resources/templates/forms/recaptcha.html.twig

Lines changed: 0 additions & 47 deletions
This file was deleted.

‎src/Captcha/Captcha.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
namespace App\Captcha;
3+
4+
use App\Captcha\Providers\HCaptchaProvider;
5+
use App\Captcha\Providers\ReCaptchaProvider;
6+
7+
final class Captcha
8+
{
9+
public const CAPTCHA_PROVIDER_RECAPTCHA = 0;
10+
public const CAPTCHA_PROVIDER_HCAPTCHA = 1;
11+
12+
public function getProviders(): array
13+
{
14+
return [
15+
self::CAPTCHA_PROVIDER_RECAPTCHA => new ReCaptchaProvider(),
16+
self::CAPTCHA_PROVIDER_HCAPTCHA => new HCaptchaProvider(),
17+
];
18+
}
19+
20+
public function getProvider(int $provider): ?CaptchaProviderInterface
21+
{
22+
return $this->getProviders()[$provider] ?? null;
23+
}
24+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
namespace App\Captcha;
3+
4+
interface CaptchaProviderInterface
5+
{
6+
public function validate(string $response, string $secretKey, ?string $userIp = null): bool;
7+
8+
public function getName(): string;
9+
10+
public function getHomePageUrl(): string;
11+
12+
public function getDocumentationUrl(): string;
13+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
namespace App\Captcha\Providers;
3+
4+
use App\Captcha\CaptchaProviderInterface;
5+
6+
class HCaptchaProvider implements CaptchaProviderInterface
7+
{
8+
private string $verifyUrl = 'https://api.hcaptcha.com/siteverify';
9+
10+
public function validate(string $response, string $secretKey, ?string $userIp = null): bool
11+
{
12+
if (empty($response)) {
13+
return false;
14+
}
15+
16+
$data = [
17+
'secret' => $secretKey,
18+
'response' => $response,
19+
'remoteip' => $userIp
20+
];
21+
22+
$options = [
23+
'http' => [
24+
'header' => "Content-type: application/x-www-form-urlencoded\r\n",
25+
'method' => 'POST',
26+
'content' => http_build_query($data)
27+
]
28+
];
29+
30+
$context = stream_context_create($options);
31+
$response = file_get_contents($this->verifyUrl, false, $context);
32+
if ($response === false) {
33+
return false;
34+
}
35+
36+
$result = json_decode($response);
37+
return $result->success;
38+
}
39+
40+
public function getName(): string
41+
{
42+
return 'hCaptcha';
43+
}
44+
45+
public function getHomePageUrl(): string
46+
{
47+
return 'https://www.hcaptcha.com/';
48+
}
49+
50+
public function getDocumentationUrl(): string
51+
{
52+
return 'https://docs.hcaptcha.com/';
53+
}
54+
}

‎src/Service/ReCaptchaService.php renamed to ‎src/Captcha/Providers/ReCaptchaProvider.php

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
<?php
2-
namespace App\Service;
2+
namespace App\Captcha\Providers;
33

4-
class ReCaptchaService
4+
use App\Captcha\CaptchaProviderInterface;
5+
6+
class ReCaptchaProvider implements CaptchaProviderInterface
57
{
68
private string $verifyUrl = 'https://www.google.com/recaptcha/api/siteverify';
79

8-
public function validate(string $recaptchaResponse, string $secretKey, ?string $userIp = null): bool
10+
public function validate(string $response, string $secretKey, ?string $userIp = null): bool
911
{
10-
if (empty($recaptchaResponse)) {
12+
if (empty($response)) {
1113
return false;
1214
}
1315

1416
$data = [
1517
'secret' => $secretKey,
16-
'response' => $recaptchaResponse,
18+
'response' => $response,
1719
'remoteip' => $userIp
1820
];
1921

@@ -34,4 +36,19 @@ public function validate(string $recaptchaResponse, string $secretKey, ?string $
3436
$result = json_decode($response);
3537
return $result->success;
3638
}
39+
40+
public function getName(): string
41+
{
42+
return 'reCaptcha';
43+
}
44+
45+
public function getHomePageUrl(): string
46+
{
47+
return 'https://www.google.com/recaptcha/about/';
48+
}
49+
50+
public function getDocumentationUrl(): string
51+
{
52+
return 'https://developers.google.com/recaptcha/docs/v3';
53+
}
3754
}

‎src/Entity/Form.php

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Doctrine\ORM\Mapping as ORM;
1010
use JsonSerializable;
1111
use Symfony\Component\Validator\Constraints as Assert;
12+
use App\Captcha\Captcha;
1213

1314
#[ORM\Entity(repositoryClass: FormRepository::class)]
1415
#[ORM\Table(name: 'forms')]
@@ -17,6 +18,8 @@
1718
#[ORM\Index(columns: ['deleted_at'], name: 'forms_deleted_at_idx')]
1819
class Form implements JsonSerializable
1920
{
21+
const DEFAULT_CAPTCHA_PROVIDER = Captcha::CAPTCHA_PROVIDER_RECAPTCHA;
22+
2023
#[ORM\Id]
2124
#[ORM\GeneratedValue]
2225
#[ORM\Column]
@@ -41,9 +44,18 @@ class Form implements JsonSerializable
4144
#[ORM\OneToMany(mappedBy: 'form_id', targetEntity: FormField::class, orphanRemoval: true)]
4245
private Collection $fields;
4346

47+
/**
48+
* @deprecated version 0.2.2 Use captcha_token instead
49+
*/
4450
#[ORM\Column(type: 'string', nullable: true)]
4551
private ?string $recaptcha_token = null;
4652

53+
#[ORM\Column(type: 'string', nullable: true)]
54+
private ?string $captcha_token = null;
55+
56+
#[ORM\Column(options: ['default' => self::DEFAULT_CAPTCHA_PROVIDER])]
57+
private int $captcha_provider = self::DEFAULT_CAPTCHA_PROVIDER;
58+
4759
#[ORM\Column(type: 'string', unique: true)]
4860
private ?string $hash = null;
4961

@@ -179,18 +191,52 @@ public function removeField(FormField $field): static
179191
return $this;
180192
}
181193

194+
/**
195+
* @deprecated version 0.2.2 Use getCaptchaToken instead
196+
*/
182197
public function getRecaptchaToken(): ?string
183198
{
184199
return $this->recaptcha_token;
185200
}
186201

202+
/**
203+
* @deprecated version 0.2.2 Use setCaptchaToken instead
204+
*/
187205
public function setRecaptchaToken(?string $recaptcha_token): static
188206
{
189207
$this->recaptcha_token = $recaptcha_token;
190208

191209
return $this;
192210
}
193211

212+
public function getCaptchaToken(): ?string
213+
{
214+
return $this->captcha_token ?? $this->recaptcha_token ?? null;
215+
}
216+
217+
public function setCaptchaToken(?string $captcha_token): static
218+
{
219+
$this->captcha_token = $captcha_token;
220+
221+
return $this;
222+
}
223+
224+
public function getCaptchaProvider(): int
225+
{
226+
return $this->captcha_provider ?? self::DEFAULT_CAPTCHA_PROVIDER;
227+
}
228+
229+
public function setCaptchaProvider(?int $captcha_provider): static
230+
{
231+
if ($captcha_provider === null) {
232+
$captcha_provider = self::DEFAULT_CAPTCHA_PROVIDER;
233+
}
234+
235+
$this->captcha_provider = $captcha_provider;
236+
237+
return $this;
238+
}
239+
194240
public function getHash(): ?string
195241
{
196242
return $this->hash;
@@ -215,9 +261,9 @@ public function setSecret(?string $secret): static
215261
return $this;
216262
}
217263

218-
public function isRecaptchaEnabled(): bool
264+
public function isCaptchaEnabled(): bool
219265
{
220-
return $this->getRecaptchaToken() !== null && strlen($this->getRecaptchaToken()) > 0;
266+
return $this->getCaptchaToken() !== null && strlen($this->getCaptchaToken()) > 0;
221267
}
222268

223269
public function isTokenEnabled(): bool
@@ -237,7 +283,7 @@ public function jsonSerialize(): array
237283
'name' => $this->getName(),
238284
'createdAt' => $this->getCreatedAt(),
239285
'deletedAt' => $this->getDeletedAt(),
240-
'recaptchaToken' => $this->getRecaptchaToken(),
286+
'captchaToken' => $this->getCaptchaToken(),
241287
'secret' => $this->getSecret(),
242288
'hash' => $this->getHash(),
243289
];

‎src/PublicApi/Controller/FormController.php

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22

33
namespace App\PublicApi\Controller;
44

5+
use App\Captcha\Captcha;
56
use App\Service\FormFieldService;
67
use App\Service\FormService;
78
use App\Service\FormSubmissionService;
8-
use App\Service\ReCaptchaService;
99
use App\Service\TokenProtectionService;
1010
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
1111
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -18,7 +18,7 @@ public function __construct(
1818
private readonly FormService $formService,
1919
private readonly FormFieldService $formFieldService,
2020
private readonly FormSubmissionService $formSubmissionService,
21-
private readonly ReCaptchaService $reCaptchaService,
21+
private readonly Captcha $captcha,
2222
)
2323
{
2424
}
@@ -44,16 +44,19 @@ public function form(Request $request, string $hash): JsonResponse
4444
if (!$data) {
4545
return $this->json(['error' => 'Invalid request body'], 400);
4646
}
47-
48-
// Recaptcha validation
49-
if($this->getParameter('env') === 'prod' && $form->isRecaptchaEnabled()) {
50-
$recaptchaResponse = $data['recaptchaResponse'] ?? null;
51-
if (!$recaptchaResponse) {
52-
return $this->json(['error' => 'Missing recaptcha response'], 400);
47+
// && $this->getParameter('env') === 'prod'
48+
if($form->isCaptchaEnabled()) {
49+
$captchaResponse = $data['captchaResponse'] ?? $data['recaptchaResponse'] ?? null;
50+
if (!$captchaResponse) {
51+
return $this->json(['error' => 'Missing captcha response'], 400);
52+
}
53+
$captchaProvider = $this->captcha->getProvider($form->getCaptchaProvider());
54+
if (!$captchaProvider) {
55+
return $this->json(['error' => 'Invalid captcha provider'], 400);
5356
}
54-
$recaptchaValidationResult = $this->reCaptchaService->validate($recaptchaResponse, $form->getRecaptchaToken(), $request->getClientIp());
55-
if (!$recaptchaValidationResult) {
56-
return $this->json(['error' => 'Invalid recaptcha'], 400);
57+
$captchaValidationResult = $captchaProvider->validate($captchaResponse, $form->getCaptchaToken(), $request->getClientIp());
58+
if (!$captchaValidationResult) {
59+
return $this->json(['error' => 'Invalid captcha'], 400);
5760
}
5861
}
5962

0 commit comments

Comments
 (0)
Please sign in to comment.