commit c2c3078f6dc1ebf965b74b9155c9c5c1b0673665 Author: Melvin Achterhuis <melvin@weprovide.com> Date: Wed Sep 28 20:52:59 2022 +0200 Initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..3701ec3 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +## A plugin for [Shopware 6](https://github.com/shopware/platform) + +**Proof-of-concept**: Integrates CloudFlare Turnstile with Shopware 6. + +Create a free account to claim your keys: https://www.cloudflare.com/en-gb/lp/turnstile/ + +*Config:* + + +Known issues: +* Not working when form in modal +* No alert when captcha invalid +* Missing translations for German +* .. + +## Requirements + +| Version | Requirements | +|------------|---------------------------- | +| 0.1.0 | Shopware 6.4 >= | + +## License + +Plugin's Icon by [flaticon](https://www.flaticon.com). + +The plugin is released under MIT. For a full overview check the [LICENSE](./LICENSE) file. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..df44615 --- /dev/null +++ b/composer.json @@ -0,0 +1,28 @@ +{ + "name": "melvinachterhuis/turnstile-shopware6-plugin", + "description": "CloudFlare Turnstile Captcha", + "version": "0.1.0", + "type": "shopware-platform-plugin", + "license": "MIT", + "authors": [ + { + "name": "Melvin Achterhuis" + } + ], + "require": { + "shopware/core": "6.4.*", + "ext-curl": "*" + }, + "autoload": { + "psr-4": { + "Melv\\Turnstile\\": "src/" + } + }, + "extra": { + "shopware-plugin-class": "Melv\\Turnstile\\MelvTurnstile", + "label": { + "de-DE": "CloudFlare Turnstile Captcha", + "en-GB": "CloudFlare Turnstile Captcha" + } + } +} diff --git a/src/MelvTurnstile.php b/src/MelvTurnstile.php new file mode 100644 index 0000000..4d5d712 --- /dev/null +++ b/src/MelvTurnstile.php @@ -0,0 +1,9 @@ +<?php declare(strict_types=1); + +namespace Melv\Turnstile; + +use Shopware\Core\Framework\Plugin; + +class MelvTurnstile extends Plugin +{ +} diff --git a/src/Migration/Migration1664374217addTurnStileCaptcha.php b/src/Migration/Migration1664374217addTurnStileCaptcha.php new file mode 100644 index 0000000..bcd3c2c --- /dev/null +++ b/src/Migration/Migration1664374217addTurnStileCaptcha.php @@ -0,0 +1,80 @@ +<?php declare(strict_types=1); + +namespace Melv\Turnstile\Migration; + +use Doctrine\DBAL\Connection; +use Shopware\Core\Defaults; +use Shopware\Core\Framework\Migration\MigrationStep; + +class Migration1664374217addTurnStileCaptcha extends MigrationStep +{ + private const CONFIG_KEY = 'core.basicInformation.activeCaptchasV2'; + + private array $captchaItems = [ + 'honeypot' => [ + 'name' => 'Honeypot', + 'isActive' => false, + ], + 'basicCaptcha' => [ + 'name' => 'basicCaptcha', + 'isActive' => false, + ], + 'googleReCaptchaV2' => [ + 'name' => 'googleReCaptchaV2', + 'isActive' => false, + 'config' => [ + 'siteKey' => '', + 'secretKey' => '', + 'invisible' => false, + ], + ], + 'googleReCaptchaV3' => [ + 'name' => 'googleReCaptchaV3', + 'isActive' => false, + 'config' => [ + 'siteKey' => '', + 'secretKey' => '', + 'thresholdScore' => 0.5, + ], + ], + 'cloudFlareTurnstile' => [ + 'name' => 'cloudFlareTurnstile', + 'isActive' => false, + 'config' => [ + 'siteKey' => '', + 'secretKey' => '' + ] + ] + ]; + + + public function getCreationTimestamp(): int + { + return 1664374217; + } + + public function update(Connection $connection): void + { + //TODO: Can we prevent overriding current CAPTCHA settings? + $configId = $connection->fetchColumn('SELECT id FROM system_config WHERE configuration_key = :key AND updated_at IS NULL', [ + 'key' => self::CONFIG_KEY, + ]); + + if (!$configId) { + return; + } + + $connection->update('system_config', [ + 'configuration_key' => self::CONFIG_KEY, + 'configuration_value' => json_encode(['_value' => $this->captchaItems]), + 'created_at' => (new \DateTime())->format(Defaults::STORAGE_DATE_TIME_FORMAT), + ], [ + 'id' => $configId, + ]); + } + + public function updateDestructive(Connection $connection): void + { + // implement update destructive + } +} diff --git a/src/Resources/app/administration/src/main.js b/src/Resources/app/administration/src/main.js new file mode 100644 index 0000000..e53e55e --- /dev/null +++ b/src/Resources/app/administration/src/main.js @@ -0,0 +1 @@ +import './module/sw-settings-basic-information/component/sw-settings-captcha-select-v2'; \ No newline at end of file diff --git a/src/Resources/app/administration/src/module/sw-settings-basic-information/component/sw-settings-captcha-select-v2/index.js b/src/Resources/app/administration/src/module/sw-settings-basic-information/component/sw-settings-captcha-select-v2/index.js new file mode 100644 index 0000000..a27ac0b --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-settings-basic-information/component/sw-settings-captcha-select-v2/index.js @@ -0,0 +1,12 @@ +import template from './sw-settings-captcha-select-v2.html.twig'; +import enGB from './snippet/en-GB.json'; +import deDE from './snippet/de-DE.json'; + +const { Component, Locale } = Shopware; + +Locale.extend('en-GB', enGB); +Locale.extend('de-DE', deDE); + +Component.override('sw-settings-captcha-select-v2', { + template, +}); \ No newline at end of file diff --git a/src/Resources/app/administration/src/module/sw-settings-basic-information/component/sw-settings-captcha-select-v2/snippet/de-DE.json b/src/Resources/app/administration/src/module/sw-settings-basic-information/component/sw-settings-captcha-select-v2/snippet/de-DE.json new file mode 100644 index 0000000..40bd863 --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-settings-basic-information/component/sw-settings-captcha-select-v2/snippet/de-DE.json @@ -0,0 +1,12 @@ +{ + "sw-settings-basic-information": { + "captcha": { + "label": { + "cloudFlareTurnstile": "CloudFlare Turnstile", + "cloudFlareTurnstileSiteKey": "CloudFlare Turnstile site key", + "cloudFlareTurnstileSecretKey": "CloudFlare Turnstile secret key", + "cloudFlareTurnstileDescription": "Turnstile is CloudFlare's CAPTCHA alternative. It automatically chooses from a rotating suite of non-intrusive browser challenges based on telemetry and client behavior exhibited during a session." + } + } + } +} \ No newline at end of file diff --git a/src/Resources/app/administration/src/module/sw-settings-basic-information/component/sw-settings-captcha-select-v2/snippet/en-GB.json b/src/Resources/app/administration/src/module/sw-settings-basic-information/component/sw-settings-captcha-select-v2/snippet/en-GB.json new file mode 100644 index 0000000..40bd863 --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-settings-basic-information/component/sw-settings-captcha-select-v2/snippet/en-GB.json @@ -0,0 +1,12 @@ +{ + "sw-settings-basic-information": { + "captcha": { + "label": { + "cloudFlareTurnstile": "CloudFlare Turnstile", + "cloudFlareTurnstileSiteKey": "CloudFlare Turnstile site key", + "cloudFlareTurnstileSecretKey": "CloudFlare Turnstile secret key", + "cloudFlareTurnstileDescription": "Turnstile is CloudFlare's CAPTCHA alternative. It automatically chooses from a rotating suite of non-intrusive browser challenges based on telemetry and client behavior exhibited during a session." + } + } + } +} \ No newline at end of file diff --git a/src/Resources/app/administration/src/module/sw-settings-basic-information/component/sw-settings-captcha-select-v2/sw-settings-captcha-select-v2.html.twig b/src/Resources/app/administration/src/module/sw-settings-basic-information/component/sw-settings-captcha-select-v2/sw-settings-captcha-select-v2.html.twig new file mode 100644 index 0000000..faa021f --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-settings-basic-information/component/sw-settings-captcha-select-v2/sw-settings-captcha-select-v2.html.twig @@ -0,0 +1,36 @@ +<!-- eslint-disable-next-line sw-deprecation-rules/no-twigjs-blocks --> +{% block sw_settings_captcha_select_v2_google_recaptcha_v2 %} + {% parent() %} + {% block sw_settings_captcha_select_v2_cloudflare_turnstile %} + <sw-container + v-if="currentValue.cloudFlareTurnstile.isActive" + class="sw-settings-captcha-select-v2__cloudflare-turnstile" + > + + <!-- eslint-disable-next-line sw-deprecation-rules/no-twigjs-blocks --> + {% block sw_settings_captcha_select_v2_cloudflare_turnstile_description %} + <p class="sw-settings-captcha-select-v2__description sw-settings-captcha-select-v2__cloudflare-turnstile-description"> + {{ $tc('sw-settings-basic-information.captcha.label.cloudFlareTurnstileDescription') }} + </p> + {% endblock %} + + <!-- eslint-disable-next-line sw-deprecation-rules/no-twigjs-blocks --> + {% block sw_settings_captcha_select_v2_cloudflare_turnstile_site_key %} + <sw-text-field + v-model="currentValue.cloudFlareTurnstile.config.siteKey" + name="cloudFlareTurnstileSiteKey" + :label="$tc('sw-settings-basic-information.captcha.label.cloudFlareTurnstileSiteKey')" + /> + {% endblock %} + + <!-- eslint-disable-next-line sw-deprecation-rules/no-twigjs-blocks --> + {% block sw_settings_captcha_select_v2_cloudflare_turnstile_secret_key %} + <sw-text-field + v-model="currentValue.cloudFlareTurnstile.config.secretKey" + name="cloudFlareTurnstileSecretKey" + :label="$tc('sw-settings-basic-information.captcha.label.cloudFlareTurnstileSecretKey')" + /> + {% endblock %} + </sw-container> + {% endblock %} +{% endblock %} \ No newline at end of file diff --git a/src/Resources/config/plugin.png b/src/Resources/config/plugin.png new file mode 100644 index 0000000..879cb7a Binary files /dev/null and b/src/Resources/config/plugin.png differ diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml new file mode 100644 index 0000000..463bbf8 --- /dev/null +++ b/src/Resources/config/services.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" ?> + +<container xmlns="http://symfony.com/schema/dic/services" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> + + <services> + <service id="Melv\Turnstile\Storefront\Framework\Captcha\CloudFlareTurnstile"> + <argument type="service" id="shopware.captcha.client"/> + <tag name="shopware.storefront.captcha" priority="50"/> + </service> + </services> +</container> \ No newline at end of file diff --git a/src/Resources/public/administration/js/melv-turnstile.js b/src/Resources/public/administration/js/melv-turnstile.js new file mode 100644 index 0000000..8989788 --- /dev/null +++ b/src/Resources/public/administration/js/melv-turnstile.js @@ -0,0 +1 @@ +!function(e){var t={};function n(l){if(t[l])return t[l].exports;var r=t[l]={i:l,l:!1,exports:{}};return e[l].call(r.exports,r,r.exports,n),r.l=!0,r.exports}n.m=e,n.c=t,n.d=function(e,t,l){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:l})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var l=Object.create(null);if(n.r(l),Object.defineProperty(l,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var r in e)n.d(l,r,function(t){return e[t]}.bind(null,r));return l},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/bundles/melvturnstile/",n(n.s="wNoc")}({"+t2p":function(e){e.exports=JSON.parse('{"sw-settings-basic-information":{"captcha":{"label":{"cloudFlareTurnstile":"CloudFlare Turnstile","cloudFlareTurnstileSiteKey":"CloudFlare Turnstile site key","cloudFlareTurnstileSecretKey":"CloudFlare Turnstile secret key","cloudFlareTurnstileDescription":"Turnstile is CloudFlare\'s CAPTCHA alternative. It automatically chooses from a rotating suite of non-intrusive browser challenges based on telemetry and client behavior exhibited during a session."}}}}')},"R+yN":function(e){e.exports=JSON.parse('{"sw-settings-basic-information":{"captcha":{"label":{"cloudFlareTurnstile":"CloudFlare Turnstile","cloudFlareTurnstileSiteKey":"CloudFlare Turnstile site key","cloudFlareTurnstileSecretKey":"CloudFlare Turnstile secret key","cloudFlareTurnstileDescription":"Turnstile is CloudFlare\'s CAPTCHA alternative. It automatically chooses from a rotating suite of non-intrusive browser challenges based on telemetry and client behavior exhibited during a session."}}}}')},wNoc:function(e,t,n){"use strict";n.r(t);var l=n("+t2p"),r=n("R+yN"),s=Shopware,i=s.Component,c=s.Locale;c.extend("en-GB",l),c.extend("de-DE",r),i.override("sw-settings-captcha-select-v2",{template:'\n{% block sw_settings_captcha_select_v2_google_recaptcha_v2 %}\n {% parent() %}\n {% block sw_settings_captcha_select_v2_cloudflare_turnstile %}\n <sw-container\n v-if="currentValue.cloudFlareTurnstile.isActive"\n class="sw-settings-captcha-select-v2__cloudflare-turnstile"\n >\n\n \n {% block sw_settings_captcha_select_v2_cloudflare_turnstile_description %}\n <p class="sw-settings-captcha-select-v2__description sw-settings-captcha-select-v2__cloudflare-turnstile-description">\n {{ $tc(\'sw-settings-basic-information.captcha.label.cloudFlareTurnstileDescription\') }}\n </p>\n {% endblock %}\n\n \n {% block sw_settings_captcha_select_v2_cloudflare_turnstile_site_key %}\n <sw-text-field\n v-model="currentValue.cloudFlareTurnstile.config.siteKey"\n name="cloudFlareTurnstileSiteKey"\n :label="$tc(\'sw-settings-basic-information.captcha.label.cloudFlareTurnstileSiteKey\')"\n />\n {% endblock %}\n\n \n {% block sw_settings_captcha_select_v2_cloudflare_turnstile_secret_key %}\n <sw-text-field\n v-model="currentValue.cloudFlareTurnstile.config.secretKey"\n name="cloudFlareTurnstileSecretKey"\n :label="$tc(\'sw-settings-basic-information.captcha.label.cloudFlareTurnstileSecretKey\')"\n />\n {% endblock %}\n </sw-container>\n {% endblock %}\n{% endblock %}'})}}); \ No newline at end of file diff --git a/src/Resources/snippet/storefront.de-DE.json b/src/Resources/snippet/storefront.de-DE.json new file mode 100644 index 0000000..a585d78 --- /dev/null +++ b/src/Resources/snippet/storefront.de-DE.json @@ -0,0 +1,7 @@ +{ + "captcha": { + "cloudFlareTurnstile": { + "dataProtectionInformation": "This site is protected by Turnstile and the CloudFlare <a href=\"https://www.cloudflare.com/en-gb/privacypolicy/\">Privacy Policy</a> and <a href=\"https://www.cloudflare.com/en-gb/website-terms/\">Terms of Service</a> apply." + } + } +} \ No newline at end of file diff --git a/src/Resources/snippet/storefront.en-GB.json b/src/Resources/snippet/storefront.en-GB.json new file mode 100644 index 0000000..a585d78 --- /dev/null +++ b/src/Resources/snippet/storefront.en-GB.json @@ -0,0 +1,7 @@ +{ + "captcha": { + "cloudFlareTurnstile": { + "dataProtectionInformation": "This site is protected by Turnstile and the CloudFlare <a href=\"https://www.cloudflare.com/en-gb/privacypolicy/\">Privacy Policy</a> and <a href=\"https://www.cloudflare.com/en-gb/website-terms/\">Terms of Service</a> apply." + } + } +} \ No newline at end of file diff --git a/src/Resources/views/storefront/component/captcha/cloudFlareTurnstile.html.twig b/src/Resources/views/storefront/component/captcha/cloudFlareTurnstile.html.twig new file mode 100644 index 0000000..faf59a0 --- /dev/null +++ b/src/Resources/views/storefront/component/captcha/cloudFlareTurnstile.html.twig @@ -0,0 +1,9 @@ +{% block component_captcha_cloudflare_turnstile %} + <div class="cf-turnstile mb-2" + data-sitekey="{{ config('core.basicInformation.activeCaptchasV2.cloudFlareTurnstile.config.siteKey') }}"> + + <div class="data-protection-information cf_turnstile-protection-information mb-2"> + {{ "captcha.cloudFlareTurnstile.dataProtectionInformation"|trans|sw_sanitize }} + </div> + </div> +{% endblock %} diff --git a/src/Resources/views/storefront/component/recaptcha.html.twig b/src/Resources/views/storefront/component/recaptcha.html.twig new file mode 100644 index 0000000..19e187f --- /dev/null +++ b/src/Resources/views/storefront/component/recaptcha.html.twig @@ -0,0 +1,11 @@ +{% sw_extends '@Storefront/storefront/component/recaptcha.html.twig' %} + +{% block component_head_javascript_recaptcha %} + {{ parent() }} + {% block component_head_javascript_turnstile %} + {% set turnstileActive = config('core.basicInformation.activeCaptchasV2.cloudFlareTurnstile.isActive') %} + {% if turnstileActive %} + <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> + {% endif %} + {% endblock %} +{% endblock %} \ No newline at end of file diff --git a/src/Storefront/Framework/Captcha/CloudFlareTurnstile.php b/src/Storefront/Framework/Captcha/CloudFlareTurnstile.php new file mode 100644 index 0000000..f1bbb02 --- /dev/null +++ b/src/Storefront/Framework/Captcha/CloudFlareTurnstile.php @@ -0,0 +1,77 @@ +<?php declare(strict_types=1); + +namespace Melv\Turnstile\Storefront\Framework\Captcha; + +use GuzzleHttp\ClientInterface; +use Psr\Http\Client\ClientExceptionInterface; +use Shopware\Core\Framework\Feature; +use Shopware\Storefront\Framework\Captcha\AbstractCaptcha; +use Symfony\Component\HttpFoundation\Request; + +class CloudFlareTurnstile extends AbstractCaptcha +{ + public const CAPTCHA_NAME = 'cloudFlareTurnstile'; + //Injected by CF in form + public const CAPTCHA_REQUEST_PARAMETER = 'cf-turnstile-response'; + private const CLOUDFLARE_CAPTCHA_VERIFY_ENDPOINT = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; + + private ClientInterface $client; + + /** + * @internal + */ + public function __construct(ClientInterface $client) + { + $this->client = $client; + } + + /** + * {@inheritdoc} + */ + public function isValid(Request $request /* , array $captchaConfig */): bool + { + if (\func_num_args() < 2 || !\is_array(func_get_arg(1))) { + Feature::triggerDeprecationOrThrow( + 'v6.5.0.0', + 'Method `isValid()` in `CloudFlareTurnstile` expects passing the `$captchaConfig` as array as the second parameter in v6.5.0.0.' + ); + } + + if (!$request->get(self::CAPTCHA_REQUEST_PARAMETER)) { + return false; + } + + $captchaConfig = \func_get_args()[1] ?? []; + + $secretKey = !empty($captchaConfig['config']['secretKey']) ? $captchaConfig['config']['secretKey'] : null; + + if (!\is_string($secretKey)) { + return false; + } + + try { + $response = $this->client->request('POST', self::CLOUDFLARE_CAPTCHA_VERIFY_ENDPOINT, [ + 'form_params' => [ + 'secret' => $secretKey, + 'response' => $request->get(self::CAPTCHA_REQUEST_PARAMETER), + 'remoteip' => $request->getClientIp(), + ], + ]); + + $responseRaw = $response->getBody()->getContents(); + $response = json_decode($responseRaw, true); + + return $response && (bool) $response['success']; + } catch (ClientExceptionInterface $exception) { + return false; + } + } + + /** + * {@inheritdoc} + */ + public function getName(): string + { + return self::CAPTCHA_NAME; + } +}