From 0fa2e5d02b2de5860eedba6dae8be2e0d8d82513 Mon Sep 17 00:00:00 2001 From: Aleks Shelestov Date: Mon, 26 Feb 2024 19:56:37 +0100 Subject: [PATCH] Supporting different captcha providers and hCaptcha support (#9) Supporting different captcha providers hCaptcha provider --------- Co-authored-by: Aleks S --- CHANGELOG.md | 12 ++++ README.md | 4 +- src/Admin/Controller/FormController.php | 24 +++++-- ...mRecaptchaType.php => FormCaptchaType.php} | 13 +++- .../Resources/templates/bulma_theme.html.twig | 6 +- .../templates/forms/captcha.html.twig | 65 +++++++++++++++++++ .../Resources/templates/forms/index.html.twig | 4 +- .../Resources/templates/forms/page.html.twig | 6 +- .../templates/forms/recaptcha.html.twig | 47 -------------- src/Captcha/Captcha.php | 24 +++++++ src/Captcha/CaptchaProviderInterface.php | 13 ++++ src/Captcha/Providers/HCaptchaProvider.php | 54 +++++++++++++++ .../Providers/ReCaptchaProvider.php} | 27 ++++++-- src/Entity/Form.php | 52 ++++++++++++++- src/PublicApi/Controller/FormController.php | 25 +++---- 15 files changed, 292 insertions(+), 84 deletions(-) create mode 100644 CHANGELOG.md rename src/Admin/Form/{FormRecaptchaType.php => FormCaptchaType.php} (51%) create mode 100644 src/Admin/Resources/templates/forms/captcha.html.twig delete mode 100644 src/Admin/Resources/templates/forms/recaptcha.html.twig create mode 100644 src/Captcha/Captcha.php create mode 100644 src/Captcha/CaptchaProviderInterface.php create mode 100644 src/Captcha/Providers/HCaptchaProvider.php rename src/{Service/ReCaptchaService.php => Captcha/Providers/ReCaptchaProvider.php} (54%) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0d5b96e --- /dev/null +++ b/CHANGELOG.md @@ -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 \ No newline at end of file diff --git a/README.md b/README.md index 56d024c..2b6b2e4 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/src/Admin/Controller/FormController.php b/src/Admin/Controller/FormController.php index 529df76..ea4b453 100644 --- a/src/Admin/Controller/FormController.php +++ b/src/Admin/Controller/FormController.php @@ -1,8 +1,10 @@ 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()) ]); } diff --git a/src/Admin/Form/FormRecaptchaType.php b/src/Admin/Form/FormCaptchaType.php similarity index 51% rename from src/Admin/Form/FormRecaptchaType.php rename to src/Admin/Form/FormCaptchaType.php index c6a1e1d..4f25dcc 100644 --- a/src/Admin/Form/FormRecaptchaType.php +++ b/src/Admin/Form/FormCaptchaType.php @@ -1,18 +1,27 @@ 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']); } diff --git a/src/Admin/Resources/templates/bulma_theme.html.twig b/src/Admin/Resources/templates/bulma_theme.html.twig index 95db24a..231101a 100644 --- a/src/Admin/Resources/templates/bulma_theme.html.twig +++ b/src/Admin/Resources/templates/bulma_theme.html.twig @@ -1,7 +1,9 @@ {% block form_row %}
- {{ form_label(form) }} -
+ +
{{ form_widget(form) }}
{{ form_errors(form) }} diff --git a/src/Admin/Resources/templates/forms/captcha.html.twig b/src/Admin/Resources/templates/forms/captcha.html.twig new file mode 100644 index 0000000..dfb4a03 --- /dev/null +++ b/src/Admin/Resources/templates/forms/captcha.html.twig @@ -0,0 +1,65 @@ +{% extends '@Admin/forms/page.html.twig' %} + +{% block title %}API / {{ formEntity.name }} / Forms{% endblock %} + +{% block section %} +
+
+

+ Protect form with captcha +

+
+
+
+
+ {{ form_start(form) }} +

+ Generate your Secret Key at . + 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. +

+ {{ form_row(form.captcha_provider, { + 'attr': {'@change': 'change($event.target.value)'} + }) }} + {{ form_row(form.captcha_token) }} +

+ Keep the field empty to disable Captcha Protection. +

+

+ Example of how to include the token in the request: +

+
+
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
+    }),
+})
+
+

+ More information on how to use it can be found here. +

+ {{ form_row(form.save) }} + {{ form_end(form) }} +
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/src/Admin/Resources/templates/forms/index.html.twig b/src/Admin/Resources/templates/forms/index.html.twig index 18d8a25..93f11eb 100644 --- a/src/Admin/Resources/templates/forms/index.html.twig +++ b/src/Admin/Resources/templates/forms/index.html.twig @@ -67,8 +67,8 @@ {% endif %} {% if app.user.isSuperUser %} - {% if (form.recaptchaToken|length == 0 and form.secret|length == 0) %} - + {% if (form.captchaToken|length == 0 and form.secret|length == 0) %} + diff --git a/src/Admin/Resources/templates/forms/page.html.twig b/src/Admin/Resources/templates/forms/page.html.twig index 0871313..a6f8f3a 100644 --- a/src/Admin/Resources/templates/forms/page.html.twig +++ b/src/Admin/Resources/templates/forms/page.html.twig @@ -82,12 +82,12 @@
  • - reCAPTCHA Protection + Captcha Protection
  • diff --git a/src/Admin/Resources/templates/forms/recaptcha.html.twig b/src/Admin/Resources/templates/forms/recaptcha.html.twig deleted file mode 100644 index 2ab7142..0000000 --- a/src/Admin/Resources/templates/forms/recaptcha.html.twig +++ /dev/null @@ -1,47 +0,0 @@ -{% extends '@Admin/forms/page.html.twig' %} - -{% block title %}API / {{ formEntity.name }} / Forms{% endblock %} - -{% block section %} -
    -
    -

    - Protect form with reCaptcha -

    -
    -
    -
    -
    - {{ form_start(form) }} -

    - Generate your token at Google reCAPTCHA. - 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 'recaptchaResponse' key. -

    - {{ form_row(form.recaptcha_token) }} -

    - Keep the field empty to disable reCAPTCHA. -

    -

    - Example of how to include the token in the request: -

    -
    fetch('https://your-domain.com/api/forms/{{ formEntity.id }}', {
    -    method: 'POST',
    -    headers: {
    -        'Content-Type': 'application/json',
    -    },
    -    body: JSON.stringify({
    -        recaptchaResponse: 'recaptcha-token-here',
    -        // other form fields
    -    }),
    -})
    -

    - More information on how to use reCAPTCHA can be found here. -

    - {{ form_row(form.save) }} - {{ form_end(form) }} -
    -
    -
    -
    -{% endblock %} diff --git a/src/Captcha/Captcha.php b/src/Captcha/Captcha.php new file mode 100644 index 0000000..2724490 --- /dev/null +++ b/src/Captcha/Captcha.php @@ -0,0 +1,24 @@ + new ReCaptchaProvider(), + self::CAPTCHA_PROVIDER_HCAPTCHA => new HCaptchaProvider(), + ]; + } + + public function getProvider(int $provider): ?CaptchaProviderInterface + { + return $this->getProviders()[$provider] ?? null; + } +} \ No newline at end of file diff --git a/src/Captcha/CaptchaProviderInterface.php b/src/Captcha/CaptchaProviderInterface.php new file mode 100644 index 0000000..1503969 --- /dev/null +++ b/src/Captcha/CaptchaProviderInterface.php @@ -0,0 +1,13 @@ + $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/'; + } +} diff --git a/src/Service/ReCaptchaService.php b/src/Captcha/Providers/ReCaptchaProvider.php similarity index 54% rename from src/Service/ReCaptchaService.php rename to src/Captcha/Providers/ReCaptchaProvider.php index 5c3832e..895899c 100644 --- a/src/Service/ReCaptchaService.php +++ b/src/Captcha/Providers/ReCaptchaProvider.php @@ -1,19 +1,21 @@ $secretKey, - 'response' => $recaptchaResponse, + 'response' => $response, 'remoteip' => $userIp ]; @@ -34,4 +36,19 @@ public function validate(string $recaptchaResponse, string $secretKey, ?string $ $result = json_decode($response); return $result->success; } + + public function getName(): string + { + return 'reCaptcha'; + } + + public function getHomePageUrl(): string + { + return 'https://www.google.com/recaptcha/about/'; + } + + public function getDocumentationUrl(): string + { + return 'https://developers.google.com/recaptcha/docs/v3'; + } } diff --git a/src/Entity/Form.php b/src/Entity/Form.php index 8c4c980..b234ac8 100644 --- a/src/Entity/Form.php +++ b/src/Entity/Form.php @@ -9,6 +9,7 @@ use Doctrine\ORM\Mapping as ORM; use JsonSerializable; use Symfony\Component\Validator\Constraints as Assert; +use App\Captcha\Captcha; #[ORM\Entity(repositoryClass: FormRepository::class)] #[ORM\Table(name: 'forms')] @@ -17,6 +18,8 @@ #[ORM\Index(columns: ['deleted_at'], name: 'forms_deleted_at_idx')] class Form implements JsonSerializable { + const DEFAULT_CAPTCHA_PROVIDER = Captcha::CAPTCHA_PROVIDER_RECAPTCHA; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] @@ -41,9 +44,18 @@ class Form implements JsonSerializable #[ORM\OneToMany(mappedBy: 'form_id', targetEntity: FormField::class, orphanRemoval: true)] private Collection $fields; + /** + * @deprecated version 0.2.2 Use captcha_token instead + */ #[ORM\Column(type: 'string', nullable: true)] private ?string $recaptcha_token = null; + #[ORM\Column(type: 'string', nullable: true)] + private ?string $captcha_token = null; + + #[ORM\Column(options: ['default' => self::DEFAULT_CAPTCHA_PROVIDER])] + private int $captcha_provider = self::DEFAULT_CAPTCHA_PROVIDER; + #[ORM\Column(type: 'string', unique: true)] private ?string $hash = null; @@ -179,11 +191,17 @@ public function removeField(FormField $field): static return $this; } + /** + * @deprecated version 0.2.2 Use getCaptchaToken instead + */ public function getRecaptchaToken(): ?string { return $this->recaptcha_token; } + /** + * @deprecated version 0.2.2 Use setCaptchaToken instead + */ public function setRecaptchaToken(?string $recaptcha_token): static { $this->recaptcha_token = $recaptcha_token; @@ -191,6 +209,34 @@ public function setRecaptchaToken(?string $recaptcha_token): static return $this; } + public function getCaptchaToken(): ?string + { + return $this->captcha_token ?? $this->recaptcha_token ?? null; + } + + public function setCaptchaToken(?string $captcha_token): static + { + $this->captcha_token = $captcha_token; + + return $this; + } + + public function getCaptchaProvider(): int + { + return $this->captcha_provider ?? self::DEFAULT_CAPTCHA_PROVIDER; + } + + public function setCaptchaProvider(?int $captcha_provider): static + { + if ($captcha_provider === null) { + $captcha_provider = self::DEFAULT_CAPTCHA_PROVIDER; + } + + $this->captcha_provider = $captcha_provider; + + return $this; + } + public function getHash(): ?string { return $this->hash; @@ -215,9 +261,9 @@ public function setSecret(?string $secret): static return $this; } - public function isRecaptchaEnabled(): bool + public function isCaptchaEnabled(): bool { - return $this->getRecaptchaToken() !== null && strlen($this->getRecaptchaToken()) > 0; + return $this->getCaptchaToken() !== null && strlen($this->getCaptchaToken()) > 0; } public function isTokenEnabled(): bool @@ -237,7 +283,7 @@ public function jsonSerialize(): array 'name' => $this->getName(), 'createdAt' => $this->getCreatedAt(), 'deletedAt' => $this->getDeletedAt(), - 'recaptchaToken' => $this->getRecaptchaToken(), + 'captchaToken' => $this->getCaptchaToken(), 'secret' => $this->getSecret(), 'hash' => $this->getHash(), ]; diff --git a/src/PublicApi/Controller/FormController.php b/src/PublicApi/Controller/FormController.php index f13fadf..36b3998 100644 --- a/src/PublicApi/Controller/FormController.php +++ b/src/PublicApi/Controller/FormController.php @@ -2,10 +2,10 @@ namespace App\PublicApi\Controller; +use App\Captcha\Captcha; use App\Service\FormFieldService; use App\Service\FormService; use App\Service\FormSubmissionService; -use App\Service\ReCaptchaService; use App\Service\TokenProtectionService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; @@ -18,7 +18,7 @@ public function __construct( private readonly FormService $formService, private readonly FormFieldService $formFieldService, private readonly FormSubmissionService $formSubmissionService, - private readonly ReCaptchaService $reCaptchaService, + private readonly Captcha $captcha, ) { } @@ -44,16 +44,19 @@ public function form(Request $request, string $hash): JsonResponse if (!$data) { return $this->json(['error' => 'Invalid request body'], 400); } - - // Recaptcha validation - if($this->getParameter('env') === 'prod' && $form->isRecaptchaEnabled()) { - $recaptchaResponse = $data['recaptchaResponse'] ?? null; - if (!$recaptchaResponse) { - return $this->json(['error' => 'Missing recaptcha response'], 400); + // && $this->getParameter('env') === 'prod' + if($form->isCaptchaEnabled()) { + $captchaResponse = $data['captchaResponse'] ?? $data['recaptchaResponse'] ?? null; + if (!$captchaResponse) { + return $this->json(['error' => 'Missing captcha response'], 400); + } + $captchaProvider = $this->captcha->getProvider($form->getCaptchaProvider()); + if (!$captchaProvider) { + return $this->json(['error' => 'Invalid captcha provider'], 400); } - $recaptchaValidationResult = $this->reCaptchaService->validate($recaptchaResponse, $form->getRecaptchaToken(), $request->getClientIp()); - if (!$recaptchaValidationResult) { - return $this->json(['error' => 'Invalid recaptcha'], 400); + $captchaValidationResult = $captchaProvider->validate($captchaResponse, $form->getCaptchaToken(), $request->getClientIp()); + if (!$captchaValidationResult) { + return $this->json(['error' => 'Invalid captcha'], 400); } }