Skip to content

Commit

Permalink
PHRAS-4023 hcaptcha in Phraseanet (#4473)
Browse files Browse the repository at this point in the history
* use hcaptcha

* add conf

* use captcha-provider key instead of captchas-enabled in configuration

* fix test

* test

* bump back version to rc9
  • Loading branch information
aynsix authored Feb 29, 2024
1 parent 5d1eb44 commit f02e5b8
Show file tree
Hide file tree
Showing 16 changed files with 165 additions and 35 deletions.
2 changes: 1 addition & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions config/configuration.sample.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docker/nginx/root/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions lib/Alchemy/Phrasea/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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']);
}
});
Expand Down
53 changes: 44 additions & 9 deletions lib/Alchemy/Phrasea/Authentication/Phrasea/FailureManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand All @@ -52,6 +56,8 @@ public function __construct(AuthFailureRepository $repo, EntityManager $em, ReCa
}

$this->trials = (int)$trials;
$this->captchaProvider = $captchaProvider;
$this->hCaptchaSecret = $hCaptchaSecret;
}

/**
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion lib/Alchemy/Phrasea/Controller/Api/V1Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']),
],
Expand Down
12 changes: 9 additions & 3 deletions lib/Alchemy/Phrasea/Controller/Root/LoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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("");
Expand Down Expand Up @@ -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("");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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']
Expand Down
2 changes: 1 addition & 1 deletion lib/Alchemy/Phrasea/Core/Version.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class Version
* @var string
*/

private $number = '4.1.8-rc8';
private $number = '4.1.8-rc9';

/**
* @var string
Expand Down
11 changes: 5 additions & 6 deletions lib/Alchemy/Phrasea/Form/Configuration/WebservicesFormType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,20 +32,18 @@ public function __construct(TranslatorInterface $translator)

public function buildForm(FormBuilderInterface $builder, array $options)
{
$recaptchaDoc = '<a href="http://www.google.com/recaptcha">http://www.google.com/recaptcha</a>';

$builder->add('google-charts-enabled', CheckboxType::class, [
'label' => 'Use Google Chart API',
]);
$builder->add('geonames-server', TextType::class, [
'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',
]);
Expand Down
81 changes: 81 additions & 0 deletions lib/classes/patch/418RC9PHRAS4023.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

use Alchemy\Phrasea\Application;
use Alchemy\Phrasea\Core\Configuration\PropertyAccess;

class patch_418RC9PHRAS4023 implements patchInterface
{
/** @var string */
private $release = '4.1.8-rc9';

/** @var array */
private $concern = [base::APPLICATION_BOX];

/**
* {@inheritdoc}
*/
public function get_release()
{
return $this->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']);
}
}
}
11 changes: 9 additions & 2 deletions templates/web/common/macro_captcha.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,15 @@
<div class="row-fluid">
<div class="span12">
{% set public_key = app["conf"].get(['registry', 'webservices', 'recaptcha-public-key']) %}
<div class="g-recaptcha" data-theme="dark" data-sitekey="{{ public_key }}" style="margin-top:1rem;transform:scale(0.86);-webkit-transform:scale(0.86);transform-origin:0 0;-webkit-transform-origin:0 0;"></div>
<script src="https://www.google.com/recaptcha/api.js?hl={{ app['locale'] }}" async defer></script>

{% if app["conf"].get(['registry', 'webservices', 'captcha-provider']) == 'hCaptcha' %}
<div class="h-captcha" data-sitekey="{{ public_key }}" style="margin-top:1rem;transform:scale(0.86);-webkit-transform:scale(0.86);transform-origin:0 0;-webkit-transform-origin:0 0;"></div>
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
{% else %}
<div class="g-recaptcha" data-theme="dark" data-sitekey="{{ public_key }}" style="margin-top:1rem;transform:scale(0.86);-webkit-transform:scale(0.86);transform-origin:0 0;-webkit-transform-origin:0 0;"></div>
<script src="https://www.google.com/recaptcha/api.js?hl={{ app['locale'] }}" async defer></script>
{% endif %}

</div>
</div>
{% endmacro %}
4 changes: 2 additions & 2 deletions tests/Alchemy/Tests/Phrasea/ApplicationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand Down

0 comments on commit f02e5b8

Please sign in to comment.