From 3000daab79fa5397c9533f3cecbfdce158976f41 Mon Sep 17 00:00:00 2001 From: Florian ALEXANDRE Date: Wed, 9 Oct 2024 13:51:41 +0000 Subject: [PATCH] feat: CaptchEtat bundle (#195) * feat: CaptchEtat bundle --------- Co-authored-by: Florian ALEXANDRE --- components/CaptchEtatBundle/LICENSE | 19 +++ components/CaptchEtatBundle/README.md | 95 +++++++++++ components/CaptchEtatBundle/ci-config.yaml | 4 + components/CaptchEtatBundle/composer.json | 32 ++++ .../src/bundle/AlmaviaCXCaptchEtatBundle.php | 11 ++ .../Controller/CaptchEtatController.php | 51 ++++++ .../AlmaviaCXCaptchEtatExtension.php | 32 ++++ .../Resources/config/default_settings.yaml | 14 ++ .../src/bundle/Resources/config/routes.yaml | 4 + .../src/bundle/Resources/config/services.yaml | 58 +++++++ .../Resources/public/js/captchetat-widget.js | 20 +++ .../templates/captchetat-fields.html.twig | 26 +++ .../Resources/translations/captchetat.en.yaml | 3 + .../Resources/translations/captchetat.fr.yaml | 3 + .../Resources/translations/messages.en.yaml | 2 + .../Resources/translations/messages.fr.yaml | 2 + .../CaptchEtatBundle/src/lib/Api/Gateway.php | 154 ++++++++++++++++++ .../src/lib/Api/OauthGateway.php | 107 ++++++++++++ .../src/lib/Challenge/ChallengeGenerator.php | 105 ++++++++++++ .../src/lib/Challenge/ChallengeValidator.php | 23 +++ .../MissingConfigurationException.php | 12 ++ .../src/lib/Form/Type/CaptchEtatType.php | 88 ++++++++++ .../Mapper/ButtonFieldMapperDecorator.php | 41 +++++ .../src/lib/Logger/CaptchEtatLogger.php | 114 +++++++++++++ .../CaptchEtatChallengeValidator.php | 49 ++++++ .../Constraint/CaptchEtatValidChallenge.php | 18 ++ .../src/lib/Value/CaptchEtatChallenge.php | 23 +++ 27 files changed, 1110 insertions(+) create mode 100644 components/CaptchEtatBundle/LICENSE create mode 100644 components/CaptchEtatBundle/README.md create mode 100644 components/CaptchEtatBundle/ci-config.yaml create mode 100644 components/CaptchEtatBundle/composer.json create mode 100644 components/CaptchEtatBundle/src/bundle/AlmaviaCXCaptchEtatBundle.php create mode 100644 components/CaptchEtatBundle/src/bundle/Controller/CaptchEtatController.php create mode 100644 components/CaptchEtatBundle/src/bundle/DependencyInjection/AlmaviaCXCaptchEtatExtension.php create mode 100644 components/CaptchEtatBundle/src/bundle/Resources/config/default_settings.yaml create mode 100644 components/CaptchEtatBundle/src/bundle/Resources/config/routes.yaml create mode 100644 components/CaptchEtatBundle/src/bundle/Resources/config/services.yaml create mode 100644 components/CaptchEtatBundle/src/bundle/Resources/public/js/captchetat-widget.js create mode 100644 components/CaptchEtatBundle/src/bundle/Resources/templates/captchetat-fields.html.twig create mode 100644 components/CaptchEtatBundle/src/bundle/Resources/translations/captchetat.en.yaml create mode 100644 components/CaptchEtatBundle/src/bundle/Resources/translations/captchetat.fr.yaml create mode 100644 components/CaptchEtatBundle/src/bundle/Resources/translations/messages.en.yaml create mode 100644 components/CaptchEtatBundle/src/bundle/Resources/translations/messages.fr.yaml create mode 100644 components/CaptchEtatBundle/src/lib/Api/Gateway.php create mode 100644 components/CaptchEtatBundle/src/lib/Api/OauthGateway.php create mode 100644 components/CaptchEtatBundle/src/lib/Challenge/ChallengeGenerator.php create mode 100644 components/CaptchEtatBundle/src/lib/Challenge/ChallengeValidator.php create mode 100644 components/CaptchEtatBundle/src/lib/Exceptions/MissingConfigurationException.php create mode 100644 components/CaptchEtatBundle/src/lib/Form/Type/CaptchEtatType.php create mode 100644 components/CaptchEtatBundle/src/lib/FormBuilder/FieldType/Field/Mapper/ButtonFieldMapperDecorator.php create mode 100644 components/CaptchEtatBundle/src/lib/Logger/CaptchEtatLogger.php create mode 100644 components/CaptchEtatBundle/src/lib/Validator/CaptchEtatChallengeValidator.php create mode 100644 components/CaptchEtatBundle/src/lib/Validator/Constraint/CaptchEtatValidChallenge.php create mode 100644 components/CaptchEtatBundle/src/lib/Value/CaptchEtatChallenge.php diff --git a/components/CaptchEtatBundle/LICENSE b/components/CaptchEtatBundle/LICENSE new file mode 100644 index 000000000..32cea7c57 --- /dev/null +++ b/components/CaptchEtatBundle/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2020 Novactive, https://github.com/Novactive/AlmaviaCXCaptchEtatBundle + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/components/CaptchEtatBundle/README.md b/components/CaptchEtatBundle/README.md new file mode 100644 index 000000000..eead77b1a --- /dev/null +++ b/components/CaptchEtatBundle/README.md @@ -0,0 +1,95 @@ +# AlmaviaCX CaptchEtat Bundle + +---- + +This repository is what we call a "subtree split": a read-only copy of one directory of the main repository. +It is used by Composer to allow developers to depend on specific bundles. + +If you want to report or contribute, you should instead open your issue on the main repository: https://github.com/Novactive/Nova-eZPlatform-Bundles + +Documentation is available in this repository via `.md` files but also packaged here: https://novactive.github.io/Nova-eZPlatform-Bundles/master/2FABundle/README.md.html + +---- + +This bundle provide a form type to use CaptchEtat (https://api.gouv.fr/les-api/api-captchetat) on your website + +## Installation + +### Requirements + +* Ibexa 4 +* PHP 7.4 || 8.0 + +### Use Composer + +Add the lib to your composer.json, run `composer require almaviacx/captchetatbundle` to refresh dependencies. + +### Register the bundle + +Then inject the bundle in the `config\bundles.php` of your application. + +```php + return [ + // ... + AlmaviaCX\Bundle\CaptchEtatBundle\AlmaviaCXCaptchEtatBundle::class => [ 'all'=> true ], + ]; +``` + +### Add routes + +Make sure you add this route to your routing: + +```yaml +# config/routes.yaml + +captchetat_routes: + resource: '@AlmaviaCXCaptchEtatBundle/Resources/config/routes.yaml' +``` + +### Accessibility + +For accessibility, you might want to add the following script to your JS + +```javascript +import CaptchaEtat from '../public/bundles/almaviacxcaptchetat/js/captchetat-widget' +CaptchaEtat.init() +``` + +## Configuration + +Configuration can be done throught the following environment variables + +``` +CAPTCHETAT_API_URL="https://sandbox-api.piste.gouv.fr" +CAPTCHETAT_OAUTH_URL="https://sandbox-oauth.piste.gouv.fr" +CAPTCHETAT_OAUTH_CLIENT_ID=~ +CAPTCHETAT_OAUTH_CLIENT_SECRET=~ +CAPTCHETAT_TIMEOUT="2.5" +``` + +Depending on if you use "sandbox" (default) or "production" environment, you might want to change the urls to : +``` +CAPTCHETAT_API_URL="https://api.piste.gouv.fr" +CAPTCHETAT_OAUTH_URL="https://oauth.piste.gouv.fr" +``` + +## Add captcha to your form + +```injectablephp +$builder->add( + 'captcha', CaptchEtatType::class, + [ + 'label' => 'customform.show.captcha', + ] +); +``` + +## Formbuilder forms +You can autommaticaly add the captcha to formbuilder forms by activating the following service decorator : + +```yaml +AlmaviaCX\Bundle\CaptchEtat\FormBuilder\FieldType\Field\Mapper\ButtonFieldMapperDecorator: + decorates: Ibexa\FormBuilder\FieldType\Field\Mapper\ButtonFieldMapper + arguments: + $buttonFieldMapper: '@.inner' +``` diff --git a/components/CaptchEtatBundle/ci-config.yaml b/components/CaptchEtatBundle/ci-config.yaml new file mode 100644 index 000000000..039db3543 --- /dev/null +++ b/components/CaptchEtatBundle/ci-config.yaml @@ -0,0 +1,4 @@ +install: true +test: false +repo: Novactive/AlmaviaCXCaptchEtatBundle + diff --git a/components/CaptchEtatBundle/composer.json b/components/CaptchEtatBundle/composer.json new file mode 100644 index 000000000..4ad2c51e8 --- /dev/null +++ b/components/CaptchEtatBundle/composer.json @@ -0,0 +1,32 @@ +{ + "name": "almaviacx/captchetatbundle", + "description": "Add CaptchEtat to forms", + "keywords": [ + "ibexa", + "bundle" + ], + "homepage": "https://github.com/Novactive/AlmaviaCXCaptchEtatBundle", + "type": "ibexa-bundle", + "authors": [ + { + "name": "AlmaviaCX", + "homepage": "https://almaviacx.com/expertises/web-mobile/", + "email": "dirtech.web@almaviacx.com" + } + ], + "license": [ + "MIT" + ], + "require": { + "php": "^7.4 || ^8.0", + "symfony/css-selector": "5.4.*", + "symfony/dom-crawler": "5.4.*" + }, + "autoload": { + "psr-4": { + "AlmaviaCX\\Bundle\\CaptchEtatBundle\\": "src/bundle", + "AlmaviaCX\\Bundle\\CaptchEtat\\": "src/lib" + } + }, + "extra": {} +} diff --git a/components/CaptchEtatBundle/src/bundle/AlmaviaCXCaptchEtatBundle.php b/components/CaptchEtatBundle/src/bundle/AlmaviaCXCaptchEtatBundle.php new file mode 100644 index 000000000..2cc3eaa93 --- /dev/null +++ b/components/CaptchEtatBundle/src/bundle/AlmaviaCXCaptchEtatBundle.php @@ -0,0 +1,11 @@ +gateway = $gateway; + } + + /** + * Permet au captcha (programme js/html) d'appeler l'api en passant par le serveur. + */ + public function apiSimpleCaptchaEndpointAction(Request $request): Response + { + $get = $request->get('get'); + $tech = $request->get('t'); + $type = $request->get('c'); + $content = $this->gateway->getSimpleCaptchaEndpoint($get, null, $tech, $type); + $response = new Response($content); + if ('script-include' === $get) { + $response->headers->set('Content-Type', 'text/javascript'); + } elseif ('image' === $get) { + $response->headers->set('Content-Disposition', $response->headers->makeDisposition( + ResponseHeaderBag::DISPOSITION_INLINE, + 'captcha.png' + )); + $response->headers->set('Content-Type', 'image/png'); + } elseif ('sound' === $get) { + $response->headers->set('Content-Disposition', $response->headers->makeDisposition( + ResponseHeaderBag::DISPOSITION_INLINE, + 'captcha-sound.wave' + )); + $response->headers->set('Content-Type', 'audio/wave'); + } + $response->setPrivate(); + + return $response; + } +} diff --git a/components/CaptchEtatBundle/src/bundle/DependencyInjection/AlmaviaCXCaptchEtatExtension.php b/components/CaptchEtatBundle/src/bundle/DependencyInjection/AlmaviaCXCaptchEtatExtension.php new file mode 100644 index 000000000..d43084939 --- /dev/null +++ b/components/CaptchEtatBundle/src/bundle/DependencyInjection/AlmaviaCXCaptchEtatExtension.php @@ -0,0 +1,32 @@ +load('default_settings.yaml'); + $loader->load('services.yaml'); + } + + public function prepend(ContainerBuilder $container) + { + $container->prependExtensionConfig('monolog', [ + 'channels' => ['captcha_etat'], + ]); + $container->prependExtensionConfig('twig', [ + 'form_themes' => ['captchetat-fields.html.twig'], + 'paths' => [__DIR__.'/../Resources/templates'], + ]); + } +} diff --git a/components/CaptchEtatBundle/src/bundle/Resources/config/default_settings.yaml b/components/CaptchEtatBundle/src/bundle/Resources/config/default_settings.yaml new file mode 100644 index 000000000..6509fa278 --- /dev/null +++ b/components/CaptchEtatBundle/src/bundle/Resources/config/default_settings.yaml @@ -0,0 +1,14 @@ +parameters: + #env(CAPTCHETAT_OAUTH_URL): "https://oauth.piste.gouv.fr" + env(CAPTCHETAT_OAUTH_URL): "https://sandbox-oauth.piste.gouv.fr" + env(CAPTCHETAT_OAUTH_CLIENT_ID): ~ + env(CAPTCHETAT_OAUTH_CLIENT_SECRET): ~ + #env(CAPTCHETAT_API_URL): "https://api.piste.gouv.fr" + env(CAPTCHETAT_API_URL): "https://sandbox-api.piste.gouv.fr" + env(CAPTCHETAT_TIMEOUT): "2.5" + + captchetat_oauth_url: '%env(string:CAPTCHETAT_OAUTH_URL)%' + captchetat_oauth_client_id: '%env(string:CAPTCHETAT_OAUTH_CLIENT_ID)%' + captchetat_oauth_client_secret: '%env(string:CAPTCHETAT_OAUTH_CLIENT_SECRET)%' + captchetat_api_url: '%env(string:CAPTCHETAT_API_URL)%' + captchetat_timeout: '%env(float:CAPTCHETAT_TIMEOUT)%' diff --git a/components/CaptchEtatBundle/src/bundle/Resources/config/routes.yaml b/components/CaptchEtatBundle/src/bundle/Resources/config/routes.yaml new file mode 100644 index 000000000..b4a95d075 --- /dev/null +++ b/components/CaptchEtatBundle/src/bundle/Resources/config/routes.yaml @@ -0,0 +1,4 @@ +captchetat.endpoint: + path: /api/simple-captcha-endpoint + defaults: + _controller: 'AlmaviaCX\Bundle\CaptchEtatBundle\Controller\CaptchEtatController::apiSimpleCaptchaEndpointAction' diff --git a/components/CaptchEtatBundle/src/bundle/Resources/config/services.yaml b/components/CaptchEtatBundle/src/bundle/Resources/config/services.yaml new file mode 100644 index 000000000..ac47b7014 --- /dev/null +++ b/components/CaptchEtatBundle/src/bundle/Resources/config/services.yaml @@ -0,0 +1,58 @@ +services: + AlmaviaCX\Bundle\CaptchEtat\Logger\CaptchEtatLogger: + arguments: + $innerLogger: '@Psr\Log\LoggerInterface' + + AlmaviaCX\Bundle\CaptchEtat\Api\OauthGateway: + arguments: + $client: '@Symfony\Contracts\HttpClient\HttpClientInterface' + $logger: '@AlmaviaCX\Bundle\CaptchEtat\Logger\CaptchEtatLogger' + $url: '%captchetat_oauth_url%' + $clientId: '%captchetat_oauth_client_id%' + $clientSecret: '%captchetat_oauth_client_secret%' + $timeout: '%captchetat_timeout%' + + AlmaviaCX\Bundle\CaptchEtat\Api\Gateway: + arguments: + $client: '@Symfony\Contracts\HttpClient\HttpClientInterface' + $logger: '@AlmaviaCX\Bundle\CaptchEtat\Logger\CaptchEtatLogger' + $oauthGateway: '@AlmaviaCX\Bundle\CaptchEtat\Api\OauthGateway' + $url: '%captchetat_api_url%' + $timeout: '%captchetat_timeout%' + + AlmaviaCX\Bundle\CaptchEtat\Challenge\ChallengeGenerator: + arguments: + $configResolver: '@Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface' + $localeConverter: '@Ibexa\Core\MVC\Symfony\Locale\LocaleConverterInterface' + $gateway: '@AlmaviaCX\Bundle\CaptchEtat\Api\Gateway' + $translator: '@Symfony\Contracts\Translation\TranslatorInterface' + $logger: '@AlmaviaCX\Bundle\CaptchEtat\Logger\CaptchEtatLogger' + + AlmaviaCX\Bundle\CaptchEtat\Challenge\ChallengeValidator: + arguments: + $gateway: '@AlmaviaCX\Bundle\CaptchEtat\Api\Gateway' + + AlmaviaCX\Bundle\CaptchEtat\Validator\CaptchEtatChallengeValidator: + arguments: + $challengeValidator: '@AlmaviaCX\Bundle\CaptchEtat\Challenge\ChallengeValidator' + $translator: '@Symfony\Contracts\Translation\TranslatorInterface' + tags: + - validator.constraint_validator + + AlmaviaCX\Bundle\CaptchEtat\Form\Type\CaptchEtatType: + lazy: true + arguments: + $challengeGenerator: '@AlmaviaCX\Bundle\CaptchEtat\Challenge\ChallengeGenerator' + tags: + - {name: 'form.type', alias: captchetat} + + AlmaviaCX\Bundle\CaptchEtatBundle\Controller\CaptchEtatController: + arguments: + $gateway: '@AlmaviaCX\Bundle\CaptchEtat\Api\Gateway' + tags: + - controller.service_arguments + +# AlmaviaCX\Bundle\CaptchEtat\FormBuilder\FieldType\Field\Mapper\ButtonFieldMapperDecorator: +# decorates: Ibexa\FormBuilder\FieldType\Field\Mapper\ButtonFieldMapper +# arguments: +# $buttonFieldMapper: '@.inner' diff --git a/components/CaptchEtatBundle/src/bundle/Resources/public/js/captchetat-widget.js b/components/CaptchEtatBundle/src/bundle/Resources/public/js/captchetat-widget.js new file mode 100644 index 000000000..c8d6adef1 --- /dev/null +++ b/components/CaptchEtatBundle/src/bundle/Resources/public/js/captchetat-widget.js @@ -0,0 +1,20 @@ +const captchaEtat = (function () { + function _init(container = document) { + const widgets = container.querySelectorAll('.js-captcha-widget'); + + for (const widget of widgets) { + const soundLink = widget.querySelector('.BDC_SoundLink'); + const answerInput = widget.querySelector('.captcha-input input[type="text"]'); + soundLink.addEventListener('click', function (e) { + if(answerInput) { + answer.removeAttribute('disabled'); + answerInput.focus() + } + }) + } + } + + return { init: _init }; +})(); + +export default captchaEtat; diff --git a/components/CaptchEtatBundle/src/bundle/Resources/templates/captchetat-fields.html.twig b/components/CaptchEtatBundle/src/bundle/Resources/templates/captchetat-fields.html.twig new file mode 100644 index 000000000..ab60e5aaa --- /dev/null +++ b/components/CaptchEtatBundle/src/bundle/Resources/templates/captchetat-fields.html.twig @@ -0,0 +1,26 @@ +{% block captchetat_row -%} + {{ block('form_row') }} +{%- endblock captchetat_row %} + +{% block captchetat_label %} + {{- block('form_label') }} +{% endblock captchetat_label %} + +{% block captchetat_widget %} + {# @var captcha_challenge \AlmaviaCX\Bundle\CaptchEtat\Value\CaptchEtatChallenge #} + {% apply spaceless %} + {%- set attr = attr|merge({ + 'class': 'widget-container row align-items-center js-captcha-widget' + }) -%} +
+
+ {{ captcha_challenge.captchaHtml|raw }} +
+
+
+ {{- form_widget(form.answer, { 'id': 'captchaFormulaireExtInput' } ) -}} + {{- form_widget(form.captcha_id , {'attr': {'value': captcha_challenge.captchaId}}) -}} +
+
+ {% endapply %} +{% endblock captchetat_widget %} diff --git a/components/CaptchEtatBundle/src/bundle/Resources/translations/captchetat.en.yaml b/components/CaptchEtatBundle/src/bundle/Resources/translations/captchetat.en.yaml new file mode 100644 index 000000000..2baabbb1b --- /dev/null +++ b/components/CaptchEtatBundle/src/bundle/Resources/translations/captchetat.en.yaml @@ -0,0 +1,3 @@ +form.answer.required: 'Answer is required' +form.answer.wrongAnswer: 'Wrong answer' +image_title: Captcha image diff --git a/components/CaptchEtatBundle/src/bundle/Resources/translations/captchetat.fr.yaml b/components/CaptchEtatBundle/src/bundle/Resources/translations/captchetat.fr.yaml new file mode 100644 index 000000000..761c81c8f --- /dev/null +++ b/components/CaptchEtatBundle/src/bundle/Resources/translations/captchetat.fr.yaml @@ -0,0 +1,3 @@ +form.answer.required: 'Une réponse est requise' +form.answer.wrongAnswer: 'Mauvaise réponse' +image_title: Image du captcha diff --git a/components/CaptchEtatBundle/src/bundle/Resources/translations/messages.en.yaml b/components/CaptchEtatBundle/src/bundle/Resources/translations/messages.en.yaml new file mode 100644 index 000000000..249c9535b --- /dev/null +++ b/components/CaptchEtatBundle/src/bundle/Resources/translations/messages.en.yaml @@ -0,0 +1,2 @@ +customform.show.captcha: 'Captcha' +form.captcha.input_answer: 'Captcha answer' diff --git a/components/CaptchEtatBundle/src/bundle/Resources/translations/messages.fr.yaml b/components/CaptchEtatBundle/src/bundle/Resources/translations/messages.fr.yaml new file mode 100644 index 000000000..3907bf62a --- /dev/null +++ b/components/CaptchEtatBundle/src/bundle/Resources/translations/messages.fr.yaml @@ -0,0 +1,2 @@ +customform.show.captcha: 'Captcha' +form.captcha.input_answer: 'Réponse du captcha' diff --git a/components/CaptchEtatBundle/src/lib/Api/Gateway.php b/components/CaptchEtatBundle/src/lib/Api/Gateway.php new file mode 100644 index 000000000..119c69699 --- /dev/null +++ b/components/CaptchEtatBundle/src/lib/Api/Gateway.php @@ -0,0 +1,154 @@ +oauthGateway = $oauthGateway; + $this->logger = $logger; + $this->timeout = $timeout; + $this->url = $url; + $this->client = $client; + } + + public function getSimpleCaptchaEndpoint( + string $captchaType = 'html', + ?string $mode = null, + ?string $tech = null, + string $type = 'numerique6_7CaptchaFR' + ): string { + $token = $this->oauthGateway->getOauth20Token(); + $available = [ + 'html', + 'layout-stylesheet', + 'script-include', + 'image', + 'reload-icon', + 'sound-icon', + 'reload-disabled-icon', + 'sound-disabled-icon', + 'sound', + 'p', + ]; + + if (!in_array($captchaType, $available)) { + throw new RuntimeException( + sprintf( + 'c value "%s" not alloweb. One of %s waiting', + $captchaType, + implode(', ', $available) + ) + ); + } + + $service = '/piste/captcha/simple-captcha-endpoint'; + $method = 'GET'; + + $queryParams = [ + 'get' => $captchaType, + 'c' => $type, + ]; + if ($mode) { + $queryParams['mode'] = $mode; + } + if ($tech) { + $queryParams['t'] = $tech; + } + + $url = $this->url.$service.'?'.http_build_query($queryParams); + + $option = [ + 'headers' => [ + 'Authorization' => 'Bearer '.$token, + ], + 'timeout' => $this->timeout, + 'verify_host' => false, + 'verify_peer' => false, + ]; + + $requestLog = [ + '$method' => $method, + '$url' => $url, + '$option' => $option, + ]; + + $this->logger->notice('CAPTCHEtat.request', $requestLog); + try { + $response = $this->client->request($method, $url, $option); + + return $response->getContent(); + } catch (ClientException|ServerException $httpException) { + $this->logger->logHttpException($httpException, $requestLog); + throw $httpException; + } catch (TransportException $transportException) { + $this->logger->logTransportException($transportException, $requestLog); + throw $transportException; + } + } + + public function validateChallenge( + string $captchaId, + string $answer + ): bool { + $token = $this->oauthGateway->getOauth20Token(); + $service = '/piste/captcha/valider-captcha'; + $method = 'POST'; + + $url = $this->url.$service; + + $option = [ + 'headers' => [ + 'Authorization' => 'Bearer '.$token, + 'Content-Type' => 'application/json', + ], + 'timeout' => $this->timeout, + 'body' => json_encode([ + 'id' => $captchaId, + 'code' => $answer, + ]), + ]; + + $requestLog = [ + '$method' => $method, + '$url' => $url, + '$option' => $option, + ]; + + $this->logger->notice('CAPTCHEtat.request', $requestLog); + + try { + $response = $this->client->request($method, $url, $option); + $content = $response->getContent(); + + return 'true' === $content; + } catch (ClientException|ServerException $httpException) { + $this->logger->logHttpException($httpException, $requestLog); + throw $httpException; + } catch (TransportException $transportException) { + $this->logger->logTransportException($transportException, $requestLog); + throw $transportException; + } + } +} diff --git a/components/CaptchEtatBundle/src/lib/Api/OauthGateway.php b/components/CaptchEtatBundle/src/lib/Api/OauthGateway.php new file mode 100644 index 000000000..c5569124c --- /dev/null +++ b/components/CaptchEtatBundle/src/lib/Api/OauthGateway.php @@ -0,0 +1,107 @@ +logger = $logger; + $this->timeout = $timeout; + $this->url = $url; + $this->clientSecret = $clientSecret; + $this->clientId = $clientId; + $this->client = $client; + } + + /** + * Récupération du jeton Oauth 2.0. + */ + public function getOauth20Token(): string + { + if (!$this->clientId || !$this->clientSecret) { + $this->logger->error('MissingConfigurationException'); + throw new MissingConfigurationException(); + } + + $service = '/api/oauth/token'; + $body = [ + 'grant_type' => 'client_credentials', + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'scope' => ['resource.READ', 'piste.captchetat'], + ]; + + $url = $this->url.$service; + + $option = [ + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'body' => $body, + 'timeout' => $this->timeout, + 'verify_host' => false, + 'verify_peer' => false, + ]; + + $method = 'POST'; + + $requestLog = [ + 'method' => $method, + 'url' => $url, + 'option' => $option, + ]; + + $requestLog['option']['body']['client_secret'] = ''; + + $this->logger->notice('CAPTCHEtat.request', $requestLog); + + try { + $response = $this->client->request($method, $url, $option); + if (200 !== $response->getStatusCode()) { + throw new Exception($response->getContent(false)); + } + $jsonContent = $response->getContent(); + $content = json_decode($jsonContent, true, 512, JSON_THROW_ON_ERROR); + $tokenType = $content['token_type'] ?? null; + if ('Bearer' !== $tokenType) { + throw new Exception('Not Bearer'); + } + $accessToken = $content['access_token'] ?? null; + if (!$accessToken) { + throw new Exception('Not access_token'); + } + + return $accessToken; + } catch (ClientException|ServerException $httpException) { + $this->logger->logHttpException($httpException, $requestLog); + throw $httpException; + } catch (TransportException $transportException) { + $this->logger->logTransportException($transportException, $requestLog); + throw $transportException; + } + } +} diff --git a/components/CaptchEtatBundle/src/lib/Challenge/ChallengeGenerator.php b/components/CaptchEtatBundle/src/lib/Challenge/ChallengeGenerator.php new file mode 100644 index 000000000..a51c9e3df --- /dev/null +++ b/components/CaptchEtatBundle/src/lib/Challenge/ChallengeGenerator.php @@ -0,0 +1,105 @@ +gateway = $gateway; + $this->logger = $logger; + $this->translator = $translator; + $this->localeConverter = $localeConverter; + $this->configResolver = $configResolver; + } + + public function __invoke(): CaptchEtatChallenge + { + $lang = $this->getShortLanguage(); + + return CaptchEtatChallenge::createLazyGhost(function (CaptchEtatChallenge $instance) use ($lang) { + try { + $captchaHtml = $this->getCaptchaHtml($lang); + $crawler = new Crawler($captchaHtml); + $type = 'numerique6_7CaptchaFR'; + if ('fr' !== $lang) { + $type = 'numerique6_7CaptchaEN'; + } + $captchaId = $crawler->filter('#BDC_VCID_'.$type)->attr('value'); + $instance->__construct($captchaHtml, $captchaId); + } catch (Exception $exception) { + $this->logger->logException($exception); + $instance->__construct(null, null); + } + }); + } + + protected function getCaptchaHtml(string $lang): string + { + $type = 'numerique6_7CaptchaFR'; + if ('fr' !== $lang) { + $type = 'numerique6_7CaptchaEN'; + } + + $html = $this->gateway->getSimpleCaptchaEndpoint( + 'html', + 'frontal', + null, + $type + ); + $hidden = 'style="visibility: hidden !important"'; + $html = str_replace($hidden, '', $html); + // Change the alt of image + return $this->changeImageTitle($html); + } + + protected function changeImageTitle(string $html): string + { + try { + $doc = new DOMDocument(); + $doc->loadHTML(''.$html); + $elements = $doc->getElementsByTagName('img'); + foreach ($elements as $item) { + if ('BDC_CaptchaImage' === $item->getAttribute('class')) { + $item->setAttribute('alt', $this->translator->trans('image_title', [], 'captchetat')); + break; + } + } + + return $doc->saveHTML(); + } catch (\Throwable $e) { + return $html; + } + } + + protected function getShortLanguage(): string + { + $languageCode = $this->configResolver->getParameter('languages')[0]; + $posixLocale = $this->localeConverter->convertToPOSIX($languageCode); + + return substr($posixLocale, 0, 2); + } +} diff --git a/components/CaptchEtatBundle/src/lib/Challenge/ChallengeValidator.php b/components/CaptchEtatBundle/src/lib/Challenge/ChallengeValidator.php new file mode 100644 index 000000000..ab4e3494e --- /dev/null +++ b/components/CaptchEtatBundle/src/lib/Challenge/ChallengeValidator.php @@ -0,0 +1,23 @@ +gateway = $gateway; + } + + public function isValid(string $captchaId, string $answer): bool + { + return $this->gateway->validateChallenge($captchaId, $answer); + } +} diff --git a/components/CaptchEtatBundle/src/lib/Exceptions/MissingConfigurationException.php b/components/CaptchEtatBundle/src/lib/Exceptions/MissingConfigurationException.php new file mode 100644 index 000000000..4c03e14fb --- /dev/null +++ b/components/CaptchEtatBundle/src/lib/Exceptions/MissingConfigurationException.php @@ -0,0 +1,12 @@ +challengeGenerator = $challengeGenerator; + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add( + 'answer', + TextType::class, + [ + 'label' => 'form.captcha.input_answer', + 'help' => 'form.captcha.help', + 'required' => true, + 'attr' => ['value' => ''], + ] + ); + $builder->add( + 'captcha_id', + HiddenType::class, + [ + 'attr' => ['value' => null], + ] + ); + } + + public function buildView(FormView $view, FormInterface $form, array $options): void + { + $view->vars['captcha_challenge'] = ($this->challengeGenerator)(); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'constraints' => [ + new CaptchEtatValidChallenge(), + ], + 'mapped' => false, + 'compound' => true, + 'required' => true, + 'error_bubbling' => false, + 'attr' => [ + 'class' => 'captcha-widget-container', + ], + ]); + } + + public function getBlockPrefix() + { + return 'captchetat'; + } + + public function getName(): string + { + return 'captchetat'; + } + + public static function getTranslationMessages() + { + return [ + ( new Message('form.captcha.input_answer', 'messages') )->setDesc('Captcha answer'), + ( new Message('form.captcha.help', 'messages') ) + ->setDesc('To view a new code or listen to the code, use the buttons next to the image.'), + ]; + } +} diff --git a/components/CaptchEtatBundle/src/lib/FormBuilder/FieldType/Field/Mapper/ButtonFieldMapperDecorator.php b/components/CaptchEtatBundle/src/lib/FormBuilder/FieldType/Field/Mapper/ButtonFieldMapperDecorator.php new file mode 100644 index 000000000..64130a4dc --- /dev/null +++ b/components/CaptchEtatBundle/src/lib/FormBuilder/FieldType/Field/Mapper/ButtonFieldMapperDecorator.php @@ -0,0 +1,41 @@ +buttonFieldMapper = $buttonFieldMapper; + $this->challengeGenerator = $challengeGenerator; + } + + public function mapField(FormBuilderInterface $builder, Field $field, array $constraints = []): void + { + if (!$builder->has('captcha')) { + $builder->add('captcha', CaptchEtatType::class, [ + 'label' => 'customform.show.captcha', + ]); + } + $this->buttonFieldMapper->mapField($builder, $field, $constraints); + } + + public function getSupportedField(): string + { + return $this->buttonFieldMapper->getSupportedField(); + } +} diff --git a/components/CaptchEtatBundle/src/lib/Logger/CaptchEtatLogger.php b/components/CaptchEtatBundle/src/lib/Logger/CaptchEtatLogger.php new file mode 100644 index 000000000..804886399 --- /dev/null +++ b/components/CaptchEtatBundle/src/lib/Logger/CaptchEtatLogger.php @@ -0,0 +1,114 @@ +innerLogger = $innerLogger; + } + + public function logException(Exception $exception): void + { + $message = sprintf( + 'Uncaught PHP Exception %s: "%s" at %s line %s', + get_class($exception), + $exception->getMessage(), + $exception->getFile(), + $exception->getLine() + ); + if ($exception instanceof NotFoundException || $exception instanceof UnauthorizedException) { + $this->innerLogger->warning($message, ['exception' => $exception]); + } elseif (!$exception instanceof HttpExceptionInterface || $exception->getStatusCode() >= 500) { + $this->innerLogger->critical($message, ['exception' => $exception]); + } else { + $this->innerLogger->error($message, ['exception' => $exception]); + } + } + + public function logHttpException(RuntimeException $httpException, array $requestLog): void + { + $response = $httpException->getResponse(); + $content = $response->getContent(false); + if ('application/json' === $response->getInfo('content_type')) { + $content = json_decode($content); + } + $this->innerLogger->error('CAPTCHEtat.error', [ + 'message' => $httpException->getMessage(), + 'request' => $requestLog, + 'statusCode' => $response->getStatusCode(), + 'content' => $content, + 'headers' => $response->getHeaders(false), + ]); + } + + public function logTransportException(TransportException $transportException, array $requestLog): void + { + $this->innerLogger->error('CAPTCHEtat.error', [ + 'message' => $transportException->getMessage(), + 'request' => $requestLog, + ]); + } + + public function emergency($message, array $context = []) + { + $this->innerLogger->emergency($message, $context); + } + + public function alert($message, array $context = []) + { + $this->innerLogger->alert($message, $context); + } + + public function critical($message, array $context = []) + { + $this->innerLogger->critical($message, $context); + } + + public function error($message, array $context = []) + { + $this->innerLogger->error($message, $context); + } + + public function warning($message, array $context = []) + { + $this->innerLogger->warning($message, $context); + } + + public function notice($message, array $context = []) + { + $this->innerLogger->notice($message, $context); + } + + public function info($message, array $context = []) + { + $this->innerLogger->info($message, $context); + } + + public function debug($message, array $context = []) + { + $this->innerLogger->debug($message, $context); + } + + public function log($level, $message, array $context = []) + { + $this->innerLogger->log($message, $context); + } +} diff --git a/components/CaptchEtatBundle/src/lib/Validator/CaptchEtatChallengeValidator.php b/components/CaptchEtatBundle/src/lib/Validator/CaptchEtatChallengeValidator.php new file mode 100644 index 000000000..680cabcfc --- /dev/null +++ b/components/CaptchEtatBundle/src/lib/Validator/CaptchEtatChallengeValidator.php @@ -0,0 +1,49 @@ +challengeValidator = $challengeValidator; + } + + public function validate($value, Constraint $constraint) + { + if (!$constraint instanceof CaptchEtatValidChallenge) { + throw new UnexpectedTypeException($constraint, CaptchEtatValidChallenge::class); + } + + if (!isset($value['captcha_id']) && !isset($value['answer'])) { + throw new UnexpectedValueException($value, 'array'); + } + + $captchaId = $value['captcha_id']; + $answer = $value['answer']; + if (null === $answer || null === $captchaId) { + $this->context + ->buildViolation($constraint->message) + ->addViolation(); + } elseif (!$this->challengeValidator->isValid($captchaId, $answer)) { + $this->context + ->buildViolation($constraint->message) + ->addViolation(); + } + } +} diff --git a/components/CaptchEtatBundle/src/lib/Validator/Constraint/CaptchEtatValidChallenge.php b/components/CaptchEtatBundle/src/lib/Validator/Constraint/CaptchEtatValidChallenge.php new file mode 100644 index 000000000..a146c3b7b --- /dev/null +++ b/components/CaptchEtatBundle/src/lib/Validator/Constraint/CaptchEtatValidChallenge.php @@ -0,0 +1,18 @@ +captchaId = $captchaId; + $this->captchaHtml = $captchaHtml; + } +}