From f02e5b8e5f31e0a6299a8cc367dab33417431064 Mon Sep 17 00:00:00 2001 From: Aina Sitraka <35221835+aynsix@users.noreply.github.com> Date: Thu, 29 Feb 2024 16:59:46 +0300 Subject: [PATCH] PHRAS-4023 hcaptcha in Phraseanet (#4473) * use hcaptcha * add conf * use captcha-provider key instead of captchas-enabled in configuration * fix test * test * bump back version to rc9 --- .env | 2 +- config/configuration.sample.yml | 2 + docker/nginx/root/entrypoint.sh | 2 +- lib/Alchemy/Phrasea/Application.php | 6 +- .../Authentication/Phrasea/FailureManager.php | 53 +++++++++--- .../Phrasea/Controller/Api/V1Controller.php | 2 +- .../Controller/Root/LoginController.php | 12 ++- .../Configuration/RegistryFormManipulator.php | 2 +- .../AuthenticationManagerServiceProvider.php | 4 +- lib/Alchemy/Phrasea/Core/Version.php | 2 +- .../Configuration/WebservicesFormType.php | 11 ++- lib/classes/patch/418RC9PHRAS4023.php | 81 +++++++++++++++++++ templates/web/common/macro_captcha.html.twig | 11 ++- .../Alchemy/Tests/Phrasea/ApplicationTest.php | 4 +- .../Fixtures/configuration-with-hosts.yml | 2 +- ...thenticationManagerServiceProviderTest.php | 4 +- 16 files changed, 165 insertions(+), 35 deletions(-) create mode 100644 lib/classes/patch/418RC9PHRAS4023.php diff --git a/.env b/.env index 6fd77d394a..2f04af5636 100644 --- a/.env +++ b/.env @@ -143,7 +143,7 @@ PHRASEANET_DOCKER_REGISTRY=local # Docker images tag. # @run -PHRASEANET_DOCKER_TAG=4.1.8-rc8 +PHRASEANET_DOCKER_TAG=4.1.8-rc9 # Stack Name # An optionnal Name for the stack diff --git a/config/configuration.sample.yml b/config/configuration.sample.yml index c8922b4014..7a65ab6ead 100644 --- a/config/configuration.sample.yml +++ b/config/configuration.sample.yml @@ -277,6 +277,8 @@ registry: actions: export-stamp-choice: false stamp-subdefs: false + webservices: + captcha-provider: none # none, reCaptcha , hCaptcha crossdomain: site-control: 'master-only' allow-access-from: diff --git a/docker/nginx/root/entrypoint.sh b/docker/nginx/root/entrypoint.sh index 48d554b577..d448228402 100755 --- a/docker/nginx/root/entrypoint.sh +++ b/docker/nginx/root/entrypoint.sh @@ -30,7 +30,7 @@ if [ ! -z "$GATEWAY_CSP" ]; then envsubst < "/securitycontentpolicies.sample.conf" > /etc/nginx/conf.d/securitycontentpolicies.conf else echo "Content Security policies is defined" - export GATEWAY_CSP="default-src 'self' 127.0.0.1 https://sockjs-eu.pusher.com:443 wss://ws-eu.pusher.com https://apiws.carrick-skills.com:8443 https://apiws.carrick-flow.com:8443 https://fonts.gstatic.com *.tiles.mapbox.com https://api.mapbox.com https://events.mapbox.com *.axept.io *.matomo.cloud *.newrelic.com *.nr-data.net https://www.googletagmanager.com *.google-analytics.com *.phrasea.io https://apiws.carrick-flow.com:8443 https://apiws.carrick-skills.com:8443 https://maxcdn.bootstrapcdn.com data: ; script-src 'unsafe-inline' 'unsafe-eval' 'self' https://www.gstatic.com *.alchemyasp.com *.axept.io *.matomo.cloud *.newrelic.com https://www.googletagmanager.com https://apiws.carrick-flow.com:8443 https://apiws.carrick-skills.com:8443 https://maxcdn.bootstrapcdn.com data: blob: ; style-src 'self' 'unsafe-inline' https://fonts.gstatic.com https://fonts.googleapis.com https://www.google.com https://www.gstatic.com https://apiws.carrick-flow.com:8443 https://apiws.carrick-skills.com:8443 https://maxcdn.bootstrapcdn.com ; img-src 'self' data: blob: *.tiles.mapbox.com https://axeptio.imgix.net *.cloudfront.net *.phrasea.io *.amazonaws.com https://apiws.carrick-flow.com:8443 https://apiws.carrick-skills.com:8443 https://maxcdn.bootstrapcdn.com https://www.gnu.org/graphics/ ; object-src 'self'; frame-ancestors 'self'" + export GATEWAY_CSP="default-src 'self' 127.0.0.1 *.hcaptcha.com https://sockjs-eu.pusher.com:443 wss://ws-eu.pusher.com https://apiws.carrick-skills.com:8443 https://apiws.carrick-flow.com:8443 https://fonts.gstatic.com *.tiles.mapbox.com https://api.mapbox.com https://events.mapbox.com *.axept.io *.matomo.cloud *.newrelic.com *.nr-data.net https://www.googletagmanager.com *.google-analytics.com *.phrasea.io https://apiws.carrick-flow.com:8443 https://apiws.carrick-skills.com:8443 https://maxcdn.bootstrapcdn.com data: ; script-src 'unsafe-inline' 'unsafe-eval' 'self' https://js.hcaptcha.com/ https://www.gstatic.com *.alchemyasp.com *.axept.io *.matomo.cloud *.newrelic.com https://www.googletagmanager.com https://apiws.carrick-flow.com:8443 https://apiws.carrick-skills.com:8443 https://maxcdn.bootstrapcdn.com data: blob: ; style-src 'self' 'unsafe-inline' https://fonts.gstatic.com https://fonts.googleapis.com https://www.google.com https://www.gstatic.com https://apiws.carrick-flow.com:8443 https://apiws.carrick-skills.com:8443 https://maxcdn.bootstrapcdn.com ; img-src 'self' data: blob: *.tiles.mapbox.com https://axeptio.imgix.net *.cloudfront.net *.phrasea.io *.amazonaws.com https://apiws.carrick-flow.com:8443 https://apiws.carrick-skills.com:8443 https://maxcdn.bootstrapcdn.com https://www.gnu.org/graphics/ ; object-src 'self'; frame-ancestors 'self'" echo "setting Security policies to : " $GATEWAY_CSP envsubst < "/securitycontentpolicies.sample.conf" > /etc/nginx/conf.d/securitycontentpolicies.conf fi diff --git a/lib/Alchemy/Phrasea/Application.php b/lib/Alchemy/Phrasea/Application.php index 7eab7c6592..c67ab8613d 100644 --- a/lib/Alchemy/Phrasea/Application.php +++ b/lib/Alchemy/Phrasea/Application.php @@ -464,7 +464,7 @@ public function getUnlockAccountData() */ public function requireCaptcha() { - if ($this['conf']->get(['registry', 'webservices', 'captchas-enabled'])) { + if ($this['conf']->get(['registry', 'webservices', 'captcha-provider']) != 'none') { $this['session']->set('require_captcha', true); } @@ -658,12 +658,12 @@ private function setupForm() private function setupRecaptacha() { $this['recaptcha.public-key'] = $this->share(function (Application $app) { - if ($app['conf']->get(['registry', 'webservices', 'captchas-enabled'])) { + if ($app['conf']->get(['registry', 'webservices', 'captcha-provider']) != 'none') { return $app['conf']->get(['registry', 'webservices', 'recaptcha-public-key']); } }); $this['recaptcha.private-key'] = $this->share(function (Application $app) { - if ($app['conf']->get(['registry', 'webservices', 'captchas-enabled'])) { + if ($app['conf']->get(['registry', 'webservices', 'captcha-provider']) != 'none') { return $app['conf']->get(['registry', 'webservices', 'recaptcha-private-key']); } }); diff --git a/lib/Alchemy/Phrasea/Authentication/Phrasea/FailureManager.php b/lib/Alchemy/Phrasea/Authentication/Phrasea/FailureManager.php index 2b3fc2fe15..de8a99d117 100644 --- a/lib/Alchemy/Phrasea/Authentication/Phrasea/FailureManager.php +++ b/lib/Alchemy/Phrasea/Authentication/Phrasea/FailureManager.php @@ -16,6 +16,7 @@ use Alchemy\Phrasea\Model\Repositories\AuthFailureRepository; use Doctrine\ORM\EntityManager; use Alchemy\Phrasea\Model\Entities\AuthFailure; +use GuzzleHttp\Client; use ReCaptcha\ReCaptcha; use Symfony\Component\HttpFoundation\Request; @@ -41,7 +42,10 @@ class FailureManager */ private $trials; - public function __construct(AuthFailureRepository $repo, EntityManager $em, ReCaptcha $captcha, $trials) + private $captchaProvider; + private $hCaptchaSecret; + + public function __construct(AuthFailureRepository $repo, EntityManager $em, ReCaptcha $captcha, $trials, $captchaProvider = false, $hCaptchaSecret = '') { $this->captcha = $captcha; $this->em = $em; @@ -52,6 +56,8 @@ public function __construct(AuthFailureRepository $repo, EntityManager $em, ReCa } $this->trials = (int)$trials; + $this->captchaProvider = $captchaProvider; + $this->hCaptchaSecret = $hCaptchaSecret; } /** @@ -136,20 +142,49 @@ public function removeFailureById($failureId) public function checkFailures($username, Request $request) { $failures = $this->repository->findLockedFailuresMatching($username, $request->getClientIp()); - $captchaResp = $request->get('g-recaptcha-response'); if (0 === count($failures)) { return $this; } if ($this->trials <= count($failures)) { - if ($captchaResp === null) { - throw new RequireCaptchaException('Too many failures, require captcha'); - } - - $response = $this->captcha->verify($captchaResp, $request->getClientIp()); - if (!$response->isSuccess()) { - throw new RequireCaptchaException('Please fill captcha'); + if ($this->captchaProvider == 'hCaptcha') { + $captchaResp = $request->get('h-captcha-response'); + + if ($captchaResp === null) { + throw new RequireCaptchaException('Too many failures, require captcha'); + } + + $client = new Client(); + $response = $client->post('https://hcaptcha.com/siteverify',[ + 'form_params' => [ + 'response' => $captchaResp, + 'secret' => $this->hCaptchaSecret + ] + ] + ); + + if ($response->getStatusCode() !== 200) { + throw new RequireCaptchaException($response->getReasonPhrase()); + } + + $body = $response->getBody()->getContents(); + + $body = json_decode($body, true); + + if (!$body['success']) { + throw new RequireCaptchaException('Please fill captcha'); + } + } else { + $captchaResp = $request->get('g-recaptcha-response'); + if ($captchaResp === null) { + throw new RequireCaptchaException('Too many failures, require captcha'); + } + + $response = $this->captcha->verify($captchaResp, $request->getClientIp()); + if (!$response->isSuccess()) { + throw new RequireCaptchaException('Please fill captcha'); + } } foreach ($failures as $failure) { diff --git a/lib/Alchemy/Phrasea/Controller/Api/V1Controller.php b/lib/Alchemy/Phrasea/Controller/Api/V1Controller.php index 0a23882758..3195ae4cc5 100644 --- a/lib/Alchemy/Phrasea/Controller/Api/V1Controller.php +++ b/lib/Alchemy/Phrasea/Controller/Api/V1Controller.php @@ -338,7 +338,7 @@ private function getGlobalValuesInformation() 'googleAnalyticsId' => $conf->get(['registry', 'general', 'analytics']), 'i18nWebService' => $conf->get(['registry', 'webservices', 'geonames-server']), 'recaptacha' => [ - 'active' => $conf->get(['registry', 'webservices', 'captchas-enabled']), + 'active' => ($conf->get(['registry', 'webservices', 'captcha-provider'])) != 'none' ? true : false, 'publicKey' => $conf->get(['registry', 'webservices', 'recaptcha-public-key']), 'privateKey' => $conf->get(['registry', 'webservices', 'recaptcha-private-key']), ], diff --git a/lib/Alchemy/Phrasea/Controller/Root/LoginController.php b/lib/Alchemy/Phrasea/Controller/Root/LoginController.php index 92eb0afbdf..42c84511cf 100644 --- a/lib/Alchemy/Phrasea/Controller/Root/LoginController.php +++ b/lib/Alchemy/Phrasea/Controller/Root/LoginController.php @@ -109,7 +109,7 @@ public function getDefaultTemplateVariables(Request $request) 'current_url' => $request->getUri(), 'flash_types' => $this->app->getAvailableFlashTypes(), 'recaptcha_display' => $this->app->isCaptchaRequired(), - 'recaptcha_enabled' => $conf->get(['registry', 'webservices', 'captchas-enabled']), + 'recaptcha_enabled' => ($conf->get(['registry', 'webservices', 'captcha-provider']) != 'none') ? true : false, 'unlock_usr_id' => $this->app->getUnlockAccountData(), 'guest_allowed' => $this->app->isGuestAllowed(), 'register_enable' => $this->getRegistrationManager()->isRegistrationEnabled(), @@ -190,7 +190,10 @@ public function doRegistration(Request $request) $provider = null; - if(isset($requestData['g-recaptcha-response']) && $requestData['g-recaptcha-response'] == "") { + if( + (isset($requestData['g-recaptcha-response']) && $requestData['g-recaptcha-response'] == "") || + (isset($requestData['h-captcha-response']) && $requestData['h-captcha-response'] == "") + ) { $this->app->addFlash('error', $this->app->trans('Please fill captcha')); $dateError = new FormError(""); @@ -433,7 +436,10 @@ public function forgotPassword(Request $request) $form->handleRequest($request); $requestData = $request->request->all(); - if(isset($requestData['g-recaptcha-response']) && $requestData['g-recaptcha-response'] == "") { + if( + (isset($requestData['g-recaptcha-response']) && $requestData['g-recaptcha-response'] == "") || + (isset($requestData['h-captcha-response']) && $requestData['h-captcha-response'] == "") + ) { $this->app->addFlash('error', $this->app->trans('Please fill captcha')); $dataError = new FormError(""); diff --git a/lib/Alchemy/Phrasea/Core/Configuration/RegistryFormManipulator.php b/lib/Alchemy/Phrasea/Core/Configuration/RegistryFormManipulator.php index b9b50172ef..877767836f 100644 --- a/lib/Alchemy/Phrasea/Core/Configuration/RegistryFormManipulator.php +++ b/lib/Alchemy/Phrasea/Core/Configuration/RegistryFormManipulator.php @@ -151,7 +151,7 @@ private function getDefaultData(array $config) 'webservices' => [ 'google-charts-enabled' => true, 'geonames-server' => 'https://geonames.alchemyasp.com/', - 'captchas-enabled' => false, + 'captcha-provider' => 'none', 'recaptcha-public-key' => '', 'recaptcha-private-key' => '', 'trials-before-display' => 5, diff --git a/lib/Alchemy/Phrasea/Core/Provider/AuthenticationManagerServiceProvider.php b/lib/Alchemy/Phrasea/Core/Provider/AuthenticationManagerServiceProvider.php index 22c5945f79..559b27fcd3 100644 --- a/lib/Alchemy/Phrasea/Core/Provider/AuthenticationManagerServiceProvider.php +++ b/lib/Alchemy/Phrasea/Core/Provider/AuthenticationManagerServiceProvider.php @@ -167,7 +167,7 @@ public function register(Application $app) $app['auth.native.failure-manager'] = $app->share(function (Application $app) { $authConf = $app['conf']->get(['registry', 'webservices']); - return new FailureManager($app['repo.auth-failures'], $app['orm.em'], $app['recaptcha'], isset($authConf['trials-before-display']) ? $authConf['trials-before-display'] : 9); + return new FailureManager($app['repo.auth-failures'], $app['orm.em'], $app['recaptcha'], isset($authConf['trials-before-display']) ? $authConf['trials-before-display'] : 9, isset($authConf['captcha-provider']) ? $authConf['captcha-provider'] : 'none', $authConf['recaptcha-private-key']); }); $app['auth.password-checker'] = $app->share(function (Application $app) { @@ -177,7 +177,7 @@ public function register(Application $app) $app['auth.native'] = $app->share(function (Application $app) { $authConf = $app['conf']->get(['registry', 'webservices']); - if ($authConf['captchas-enabled']) { + if ($authConf['captcha-provider'] != 'none') { return new FailureHandledNativeAuthentication( $app['auth.password-checker'], $app['auth.native.failure-manager'] diff --git a/lib/Alchemy/Phrasea/Core/Version.php b/lib/Alchemy/Phrasea/Core/Version.php index 2cd60149f6..e3e87358d5 100644 --- a/lib/Alchemy/Phrasea/Core/Version.php +++ b/lib/Alchemy/Phrasea/Core/Version.php @@ -17,7 +17,7 @@ class Version * @var string */ - private $number = '4.1.8-rc8'; + private $number = '4.1.8-rc9'; /** * @var string diff --git a/lib/Alchemy/Phrasea/Form/Configuration/WebservicesFormType.php b/lib/Alchemy/Phrasea/Form/Configuration/WebservicesFormType.php index d27db8c0dd..9b3f567338 100644 --- a/lib/Alchemy/Phrasea/Form/Configuration/WebservicesFormType.php +++ b/lib/Alchemy/Phrasea/Form/Configuration/WebservicesFormType.php @@ -14,6 +14,7 @@ use JMS\TranslationBundle\Annotation\Ignore; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Translation\TranslatorInterface; @@ -31,8 +32,6 @@ public function __construct(TranslatorInterface $translator) public function buildForm(FormBuilderInterface $builder, array $options) { - $recaptchaDoc = 'http://www.google.com/recaptcha'; - $builder->add('google-charts-enabled', CheckboxType::class, [ 'label' => 'Use Google Chart API', ]); @@ -40,11 +39,11 @@ public function buildForm(FormBuilderInterface $builder, array $options) 'label' => 'Geonames server address', ]); - $builder->add('captchas-enabled', CheckboxType::class, [ - 'label' => $this->translator->trans('Use recaptcha API'), - 'help_message' => /** @Ignore */$this->translator->trans('See documentation at %url%', ['%url%' => $recaptchaDoc]), - 'translation_domain' => false + $builder->add('captcha-provider', ChoiceType::class, [ + 'label' => 'Captcha provider', + 'choices' => ['none' => 'none', 'reCaptcha' => 'reCaptcha', 'hCaptcha' => 'hCaptcha'] ]); + $builder->add('recaptcha-public-key', TextType::class, [ 'label' => 'Recaptcha public key', ]); diff --git a/lib/classes/patch/418RC9PHRAS4023.php b/lib/classes/patch/418RC9PHRAS4023.php new file mode 100644 index 0000000000..c79da6287e --- /dev/null +++ b/lib/classes/patch/418RC9PHRAS4023.php @@ -0,0 +1,81 @@ +release; + } + + /** + * {@inheritdoc} + */ + public function getDoctrineMigrations() + { + return []; + } + + /** + * {@inheritdoc} + */ + public function require_all_upgrades() + { + return false; + } + + /** + * {@inheritdoc} + */ + public function concern() + { + return $this->concern; + } + + /** + * {@inheritdoc} + */ + public function apply(base $base, Application $app) + { + if ($base->get_base_type() === base::DATA_BOX) { + $this->patch_databox($base, $app); + } elseif ($base->get_base_type() === base::APPLICATION_BOX) { + $this->patch_appbox($base, $app); + } + + return true; + } + + private function patch_databox(databox $databox, Application $app) + { + } + + private function patch_appbox(base $appbox, Application $app) + { + /** @var PropertyAccess $conf */ + $conf = $app['conf']; + + if ($conf->has(['registry', 'webservices', 'captchas-enabled'])) { + $captchaEnabled = $conf->get(['registry', 'webservices', 'captchas-enabled']); + + if ($captchaEnabled) { + $conf->set(['registry', 'webservices', 'captcha-provider'], 'reCaptcha'); + } else { + $conf->set(['registry', 'webservices', 'captcha-provider'], 'none'); + } + + $conf->remove(['registry', 'webservices', 'captchas-enabled']); + } + } +} diff --git a/templates/web/common/macro_captcha.html.twig b/templates/web/common/macro_captcha.html.twig index 12d231c12d..3eb8a43517 100644 --- a/templates/web/common/macro_captcha.html.twig +++ b/templates/web/common/macro_captcha.html.twig @@ -2,8 +2,15 @@
{% set public_key = app["conf"].get(['registry', 'webservices', 'recaptcha-public-key']) %} -
- + + {% if app["conf"].get(['registry', 'webservices', 'captcha-provider']) == 'hCaptcha' %} +
+ + {% else %} +
+ + {% endif %} +
{% endmacro %} diff --git a/tests/Alchemy/Tests/Phrasea/ApplicationTest.php b/tests/Alchemy/Tests/Phrasea/ApplicationTest.php index 98f4fb8bc0..df53ae7b39 100644 --- a/tests/Alchemy/Tests/Phrasea/ApplicationTest.php +++ b/tests/Alchemy/Tests/Phrasea/ApplicationTest.php @@ -240,8 +240,8 @@ public function testAddCaptcha() $app['conf'] ->expects($this->any()) ->method('get') - ->with(['registry', 'webservices', 'captchas-enabled']) - ->will($this->returnValue(true)); + ->with(['registry', 'webservices', 'captcha-provider']) + ->will($this->returnValue('reCaptcha')); $this->assertFalse($app->isCaptchaRequired()); $app->requireCaptcha(); diff --git a/tests/Alchemy/Tests/Phrasea/Core/Configuration/Fixtures/configuration-with-hosts.yml b/tests/Alchemy/Tests/Phrasea/Core/Configuration/Fixtures/configuration-with-hosts.yml index 98664321ff..361d6e10f7 100644 --- a/tests/Alchemy/Tests/Phrasea/Core/Configuration/Fixtures/configuration-with-hosts.yml +++ b/tests/Alchemy/Tests/Phrasea/Core/Configuration/Fixtures/configuration-with-hosts.yml @@ -262,7 +262,7 @@ registry: webservices: google-charts-enabled: true geonames-server: 'http://geonames.alchemyasp.com/' - captchas-enabled: false + captcha-provider: 'none' recaptcha-public-key: '' recaptcha-private-key: '' executables: diff --git a/tests/Alchemy/Tests/Phrasea/Core/Provider/AuthenticationManagerServiceProviderTest.php b/tests/Alchemy/Tests/Phrasea/Core/Provider/AuthenticationManagerServiceProviderTest.php index 8fe95ede91..8b24bfbefc 100644 --- a/tests/Alchemy/Tests/Phrasea/Core/Provider/AuthenticationManagerServiceProviderTest.php +++ b/tests/Alchemy/Tests/Phrasea/Core/Provider/AuthenticationManagerServiceProviderTest.php @@ -134,7 +134,7 @@ public function testAuthNativeWithCaptchaEnabled() $app->register(new RepositoriesServiceProvider()); $app['phraseanet.appbox'] = self::$DI['app']['phraseanet.appbox']; - $app['conf']->set(['registry', 'webservices', 'captchas-enabled'], true); + $app['conf']->set(['registry', 'webservices', 'captcha-provider'], 'reCaptcha'); $bkp = $app['conf']->get('authentication'); $app['orm.em'] = $this->createEntityManagerMock(); @@ -157,7 +157,7 @@ public function testAuthNativeWithCaptchaDisabled() $app->register(new ConfigurationServiceProvider()); $app['phraseanet.appbox'] = self::$DI['app']['phraseanet.appbox']; - $app['conf']->set(['registry', 'webservices', 'captchas-enabled'], false); + $app['conf']->set(['registry', 'webservices', 'captcha-provider'], 'none'); $bkp = $app['conf']->get('authentication'); $app['orm.em'] = $this->createEntityManagerMock();