Skip to content

Commit

Permalink
Supporting different captcha providers and hCaptcha support (#9)
Browse files Browse the repository at this point in the history
Supporting different captcha providers
hCaptcha provider

---------

Co-authored-by: Aleks S <avshelestov@gmail.com>
  • Loading branch information
sdkfuyr authored Feb 26, 2024
1 parent 8185346 commit 0fa2e5d
Show file tree
Hide file tree
Showing 15 changed files with 292 additions and 84 deletions.
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
0.2.2
==
26 Frb 2024

**Added:**

* Supporting different captcha providers
* hCaptcha provider

**Migration guide:**
* Create and run BD migrations to apply new changes (see on https://phpform.dev)
* Check forms and update captcha settings if needed
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ developers and businesses looking for a reliable and GDPR-compliant form managem
- **Cost-Effective**: Designed to run smoothly on inexpensive hosting or free cloud services, reducing your operational costs.
- **GDPR Compliant**: We prioritize your data privacy. PHPForm ensures that all your data remains yours, complying fully with GDPR regulations.
- **Browser Push and Email notifications**: Get notified when a new form submission is received.
- **reCaptcha Protection**: Protect your forms from spam and abuse with Google reCaptcha.
- **Captcha Protection**: Protect your forms from spam and abuse with different Captcha providers.
- **Token-based Protection**: Ideal protection for mobile and desktop apps.

# Requirements
Expand Down Expand Up @@ -49,7 +49,7 @@ PHPForm is released under MIT, ensuring it remains free and open for use and mod
## Running locally with docker
Use the latest [image from docker hub](https://hub.docker.com/r/phpform/phpform-server) to run it locally:
```bash
docker run --name phpform -d -p 9000:9000 phpform/phpform-server:0.2
docker run --name phpform -d -p 9000:9000 phpform/phpform-server:latest
```
Copy environment file and adjust it to your needs:
```bash
Expand Down
24 changes: 17 additions & 7 deletions src/Admin/Controller/FormController.php
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
<?php
namespace App\Admin\Controller;

use App\Admin\Form\FormRecaptchaType;
use App\Admin\Form\FormCaptchaType;
use App\Admin\Form\FormSecretType;
use App\Captcha\Captcha;
use App\Captcha\CaptchaProviderInterface;
use App\Entity\Form;
use App\Admin\Form\FormType;
use App\Service\FormMenuCounterService;
Expand All @@ -20,6 +22,7 @@ public function __construct(
private readonly FormService $formService,
private readonly FormMenuCounterService $formMenuCounterService,
private readonly FormSubmissionService $formSubmissionService,
private readonly Captcha $captcha,
)
{
}
Expand Down Expand Up @@ -75,24 +78,31 @@ public function edit(Request $request, Form $formEntity): Response
]);
}

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

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

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

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

return $this->render('@Admin/forms/recaptcha.html.twig', [
return $this->render('@Admin/forms/captcha.html.twig', [
'form' => $form->createView(),
'formEntity' => $formEntity,
'menuCounts' => $this->formMenuCounterService->getAllCountsByFormId($formEntity->getId()),
'providersInfo' => array_map(static function(CaptchaProviderInterface $provider) {
return [
'name' => $provider->getName(),
'homepageUrl' => $provider->getHomePageUrl(),
'documentationUrl' => $provider->getDocumentationUrl(),
];
}, $this->captcha->getProviders())
]);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
<?php
namespace App\Admin\Form;

use App\Captcha\Captcha;
use App\Captcha\CaptchaProviderInterface;
use App\Entity\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;

class FormRecaptchaType extends AbstractType
class FormCaptchaType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('recaptcha_token', null, ['label' => 'reCAPTCHA Secret Key'])
->add('captcha_provider', ChoiceType::class, [
'label' => 'Captcha Provider',
'choices' => array_flip(array_map(static function(CaptchaProviderInterface $provider){
return $provider->getName();
}, (new Captcha())->getProviders())),
])
->add('captcha_token', null, ['label' => 'Secret Key'])
->add('save', SubmitType::class, ['label' => 'Save']);
}

Expand Down
6 changes: 4 additions & 2 deletions src/Admin/Resources/templates/bulma_theme.html.twig
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{% block form_row %}
<div class="field">
{{ form_label(form) }}
<div class="control">
<label class="has-text-weight-bold">
{{ form_label(form) }}
</label>
<div class="control mt-1">
{{ form_widget(form) }}
</div>
{{ form_errors(form) }}
Expand Down
65 changes: 65 additions & 0 deletions src/Admin/Resources/templates/forms/captcha.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
{% extends '@Admin/forms/page.html.twig' %}

{% block title %}API / {{ formEntity.name }} / Forms{% endblock %}

{% block section %}
<div class="card">
<header class="card-header">
<p class="card-header-title">
Protect form with captcha
</p>
</header>
<div class="card-content" x-data="providersData()">
<div class="content">
<form method="post">
{{ form_start(form) }}
<p>
Generate your Secret Key at <a target="_blank" :href="currentProvider.homepageUrl"><span x-html="currentProvider.name"></span></a>.
Only the Secret Key is required here.
Ensure to implement token retrieval on the frontend and include it in the PHPForm endpoint request under the 'captchaResponse' key.
</p>
{{ form_row(form.captcha_provider, {
'attr': {'@change': 'change($event.target.value)'}
}) }}
{{ form_row(form.captcha_token) }}
<p>
Keep the field empty to disable Captcha Protection.
</p>
<p>
Example of how to include the token in the request:
</p>
<div class="mb-4">
<pre style="overflow:auto; width:100%; max-width: 550px;"><code>fetch('https://your-domain.com/api/forms/{{ formEntity.hash }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
captchaResponse: 'captcha-token-here',
// other form fields
}),
})</code></pre>
</div>
<p>
More information on how to use it can be found <a target="_blank" :href="currentProvider.documentationUrl">here</a>.
</p>
{{ form_row(form.save) }}
{{ form_end(form) }}
</form>
</div>
</div>
</div>

<script>
function providersData() {
const providers = {{ providersInfo|json_encode|raw }};
return {
providers,
currentProvider: providers[0],
change: function (value) {
this.currentProvider = providers[value];
}
};
}
</script>
{% endblock %}
4 changes: 2 additions & 2 deletions src/Admin/Resources/templates/forms/index.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@
</a>
{% endif %}
{% if app.user.isSuperUser %}
{% if (form.recaptchaToken|length == 0 and form.secret|length == 0) %}
<a class="tag is-danger is-light ml-1" title="Please use token or reCaptcha protection" href="{{ path('admin_forms_token', { id: form.id }) }}">
{% if (form.captchaToken|length == 0 and form.secret|length == 0) %}
<a class="tag is-danger is-light ml-1" title="Please use token or captcha protection" href="{{ path('admin_forms_token', { id: form.id }) }}">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" style="width: 16px; height: 16px; fill: hsl(348, 100%, 61%);">
<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"/>
</svg>
Expand Down
6 changes: 3 additions & 3 deletions src/Admin/Resources/templates/forms/page.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,12 @@
</li>
<li>
<a
{% if current_path == 'admin_forms_recaptcha' %}
{% if current_path == 'admin_forms_captcha' %}
class="is-active"
{% endif %}
href="{{ path('admin_forms_recaptcha', {'id': formEntity.id}) }}"
href="{{ path('admin_forms_captcha', {'id': formEntity.id}) }}"
>
reCAPTCHA Protection
Captcha Protection
</a>
</li>
<li>
Expand Down
47 changes: 0 additions & 47 deletions src/Admin/Resources/templates/forms/recaptcha.html.twig

This file was deleted.

24 changes: 24 additions & 0 deletions src/Captcha/Captcha.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php
namespace App\Captcha;

use App\Captcha\Providers\HCaptchaProvider;
use App\Captcha\Providers\ReCaptchaProvider;

final class Captcha
{
public const CAPTCHA_PROVIDER_RECAPTCHA = 0;
public const CAPTCHA_PROVIDER_HCAPTCHA = 1;

public function getProviders(): array
{
return [
self::CAPTCHA_PROVIDER_RECAPTCHA => new ReCaptchaProvider(),
self::CAPTCHA_PROVIDER_HCAPTCHA => new HCaptchaProvider(),
];
}

public function getProvider(int $provider): ?CaptchaProviderInterface
{
return $this->getProviders()[$provider] ?? null;
}
}
13 changes: 13 additions & 0 deletions src/Captcha/CaptchaProviderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php
namespace App\Captcha;

interface CaptchaProviderInterface
{
public function validate(string $response, string $secretKey, ?string $userIp = null): bool;

public function getName(): string;

public function getHomePageUrl(): string;

public function getDocumentationUrl(): string;
}
54 changes: 54 additions & 0 deletions src/Captcha/Providers/HCaptchaProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php
namespace App\Captcha\Providers;

use App\Captcha\CaptchaProviderInterface;

class HCaptchaProvider implements CaptchaProviderInterface
{
private string $verifyUrl = 'https://api.hcaptcha.com/siteverify';

public function validate(string $response, string $secretKey, ?string $userIp = null): bool
{
if (empty($response)) {
return false;
}

$data = [
'secret' => $secretKey,
'response' => $response,
'remoteip' => $userIp
];

$options = [
'http' => [
'header' => "Content-type: application/x-www-form-urlencoded\r\n",
'method' => 'POST',
'content' => http_build_query($data)
]
];

$context = stream_context_create($options);
$response = file_get_contents($this->verifyUrl, false, $context);
if ($response === false) {
return false;
}

$result = json_decode($response);
return $result->success;
}

public function getName(): string
{
return 'hCaptcha';
}

public function getHomePageUrl(): string
{
return 'https://www.hcaptcha.com/';
}

public function getDocumentationUrl(): string
{
return 'https://docs.hcaptcha.com/';
}
}
Loading

0 comments on commit 0fa2e5d

Please sign in to comment.