Skip to content

Commit

Permalink
add ReCaptcha v3 support (#343)
Browse files Browse the repository at this point in the history
  • Loading branch information
David Coutadeur committed Jul 3, 2024
1 parent 4049d34 commit 28b5749
Show file tree
Hide file tree
Showing 4 changed files with 319 additions and 1 deletion.
6 changes: 6 additions & 0 deletions conf/config.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,12 @@
$use_captcha = false;
$captcha_class = "InternalCaptcha";

#$captcha_class = "ReCaptcha";
#$recaptcha_url = "https://www.google.com/recaptcha/api/siteverify";
#$recaptcha_sitekey = "sitekey";
#$recaptcha_secretkey = "secretkey";
#$recaptcha_minscore = 0.5;

## Default action
# change
# sendtoken
Expand Down
15 changes: 14 additions & 1 deletion docs/config_general.rst
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,20 @@ You should also define the captcha module to use.
.. tip:: The captcha is used on every form in Self Service Password
(password change, token, questions,...)

For ``$captcha_class``, you can select another captcha module. For now, only ``InternalCaptcha`` and ``FriendlyCaptcha`` are supported.
For ``$captcha_class``, you can select another captcha module. For now, only ``InternalCaptcha``, ``FriendlyCaptcha`` and ``ReCaptcha`` are supported.

If you want to set up ``ReCaptcha``, you must also configure additional parameters:

.. code-block:: php
$use_captcha = true;
$captcha_class = "ReCaptcha";
$recaptcha_url = "https://www.google.com/recaptcha/api/siteverify";
$recaptcha_sitekey = "sitekey";
$recaptcha_secretkey = "secretkey";
$recaptcha_minscore = 0.5;
See `ReCaptcha documentation <https://developers.google.com/recaptcha/docs/v3>`_ for more information

You can also add your own Captcha module. (see :doc:`developpers` )

Expand Down
137 changes: 137 additions & 0 deletions lib/captcha/ReCaptcha.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<?php namespace captcha;

require_once(__DIR__."/../../vendor/autoload.php");

class ReCaptcha
{

private $recaptcha_url;
private $recaptcha_sitekey;
private $recaptcha_secretkey;
private $recaptcha_minscore;

public function __construct($recaptcha_url, $recaptcha_sitekey, $recaptcha_secretkey, $recaptcha_minscore)
{
$this->recaptcha_url = $recaptcha_url;
$this->recaptcha_sitekey = $recaptcha_sitekey;
$this->recaptcha_secretkey = $recaptcha_secretkey;
$this->recaptcha_minscore = $recaptcha_minscore;
}

# Function that insert extra css
function generate_css_captcha(){
$captcha_css = '';

return $captcha_css;
}

# Function that insert extra js
function generate_js_captcha(){
$captcha_js = '
<script src="https://www.google.com/recaptcha/api.js?render='.$this->recaptcha_sitekey.'"></script>
<script>
$(document).ready(function(){
$(\'button[type="submit"]\').on("click", function (event) {
// only run captcha check and send form if form is valid
if( $("form")[0].checkValidity() )
{
// do not allow to send form before we get the token
event.preventDefault();
grecaptcha.execute("'.$this->recaptcha_sitekey.'", {action: "submit"}).then(function(token) {
// store the token into hidden input in the form
$("#captchaphrase").val(token);
$("form").submit(); // send form
});
}
});
});
</script>
';

return $captcha_js;
}

# Function that generate the html part containing the captcha
function generate_html_captcha($messages){

$captcha_html ='
<div class="row mb-3">
<div class="col-sm-4 col-form-label text-end captcha">
</div>
<div class="col-sm-8">
<div class="input-group">
<input type="hidden" autocomplete="new-password" name="captchaphrase" id="captchaphrase" class="form-control" />
</div>
</div>
</div>';

return $captcha_html;
}

# Function that generate the captcha challenge
# Could be called by the backend, or by a call through a REST API to define
function generate_captcha_challenge(){

$captcha_challenge = "";

return $captcha_challenge;
}

# Function that verify that the result sent by the user
# matches the captcha challenge
function verify_captcha_challenge(){
$result="";
if (isset($_POST["captchaphrase"]) and $_POST["captchaphrase"]) {
$captchaphrase = strval($_POST["captchaphrase"]);

# Call to recaptcha rest api
$data = [
'secret' => $this->recaptcha_secretkey,
'response' => "$captchaphrase"
];
$options = [
'http' => [
'header' => "Content-type: application/x-www-form-urlencoded\r\n",
'method' => 'POST',
'content' => http_build_query($data),
],
];
$context = stream_context_create($options);
$response = file_get_contents($this->recaptcha_url, false, $context);
if ($response === false) {
error_log("Error while reaching ".$this->recaptcha_url);
$result = "badcaptcha";
}
$json_response = json_decode($response);
if( $json_response->success != "true" )
{
error_log("Error while verifying captcha $captchaphrase on ".$this->recaptcha_url.": ".var_export($json_response, true));
$result = "badcaptcha";
}
else
{
if( !isset($json_response->score) ||
$json_response->score < $this->recaptcha_minscore )
{
error_log("Insufficient score: ".$json_response->score." but minimum required: ".$this->recaptcha_minscore." while verifying captcha $captchaphrase on ".$this->recaptcha_url.": ".var_export($json_response, true));
$result = "badcaptcha";
}
else
{
// captcha verified successfully
error_log("Captcha verified successfully: $captchaphrase on ".$this->recaptcha_url.": ".var_export($json_response, true));
}
}

}
else {
$result = "captcharequired";
}
return $result;
}

}


?>
162 changes: 162 additions & 0 deletions tests/ReCaptchaTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<?php

require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/captcha/ReCaptcha.php';

class ReCaptchaTest extends \PHPUnit\Framework\TestCase
{

use \phpmock\phpunit\PHPMock;

public function test_construct(): void
{
$recaptcha_url = 'http://127.0.0.1/';
$recaptcha_sitekey = 'sitekey';
$recaptcha_secretkey = 'secret';
$recaptcha_minscore = 0.5;

$captchaInstance = new captcha\ReCaptcha($recaptcha_url,
$recaptcha_sitekey,
$recaptcha_secretkey,
$recaptcha_minscore);

$this->assertEquals('captcha\ReCaptcha', get_class($captchaInstance), "Wrong class");
}

public function test_generate_js_captcha(): void
{
$recaptcha_url = 'http://127.0.0.1/';
$recaptcha_sitekey = 'sitekey';
$recaptcha_secretkey = 'secret';
$recaptcha_minscore = 0.5;

$captchaInstance = new captcha\ReCaptcha($recaptcha_url,
$recaptcha_sitekey,
$recaptcha_secretkey,
$recaptcha_minscore);

$js = $captchaInstance->generate_js_captcha();

$this->assertMatchesRegularExpression('/https:\/\/www.google.com\/recaptcha\/api.js/i',$js, "dummy js code returned");
}

public function test_generate_html_captcha(): void
{
$messages = array();

$recaptcha_url = 'http://127.0.0.1/';
$recaptcha_sitekey = 'sitekey';
$recaptcha_secretkey = 'secret';
$recaptcha_minscore = 0.5;

$captchaInstance = new captcha\ReCaptcha($recaptcha_url,
$recaptcha_sitekey,
$recaptcha_secretkey,
$recaptcha_minscore);

$html = $captchaInstance->generate_html_captcha($messages);

$this->assertMatchesRegularExpression('/<input type="hidden" autocomplete="new-password" name="captchaphrase" id="captchaphrase" class="form-control"/',$html, "dummy challenge in html code");
}

public function test_verify_captcha_challenge_ok(): void
{

$recaptcha_url = 'http://127.0.0.1/';
$recaptcha_sitekey = 'sitekey';
$recaptcha_secretkey = 'secret';
$recaptcha_minscore = 0.5;
$http_response = '{"success": "true", "score": "0.9"}';

$captchaInstance = new captcha\ReCaptcha($recaptcha_url,
$recaptcha_sitekey,
$recaptcha_secretkey,
$recaptcha_minscore);

$error_log = $this->getFunctionMock("captcha", "error_log");
$error_log->expects($this->any())->willReturn("");
$stream_context_create = $this->getFunctionMock("captcha", "stream_context_create");
$stream_context_create->expects($this->once())->willReturn("stream_context_create");
$file_get_contents = $this->getFunctionMock("captcha", "file_get_contents");
$file_get_contents->expects($this->once())->willReturn($http_response);

$_POST["captchaphrase"] = "ABCDE";
$captcha = $captchaInstance->verify_captcha_challenge();
$this->assertEquals('',$captcha, "unexpected return response during verify_captcha_challenge");
}

public function test_verify_captcha_challenge_badcaptcha(): void
{

$recaptcha_url = 'http://127.0.0.1/';
$recaptcha_sitekey = 'sitekey';
$recaptcha_secretkey = 'secret';
$recaptcha_minscore = 0.5;
$http_response = '{"success": "false"}';

$captchaInstance = new captcha\ReCaptcha($recaptcha_url,
$recaptcha_sitekey,
$recaptcha_secretkey,
$recaptcha_minscore);

$error_log = $this->getFunctionMock("captcha", "error_log");
$error_log->expects($this->any())->willReturn("");
$stream_context_create = $this->getFunctionMock("captcha", "stream_context_create");
$stream_context_create->expects($this->once())->willReturn("stream_context_create");
$file_get_contents = $this->getFunctionMock("captcha", "file_get_contents");
$file_get_contents->expects($this->once())->willReturn($http_response);

$_POST["captchaphrase"] = "ABCDE";
$captcha = $captchaInstance->verify_captcha_challenge();
$this->assertEquals('badcaptcha',$captcha, "unexpected return response during verify_captcha_challenge");
}

public function test_verify_captcha_challenge_insufficientscore(): void
{

$recaptcha_url = 'http://127.0.0.1/';
$recaptcha_sitekey = 'sitekey';
$recaptcha_secretkey = 'secret';
$recaptcha_minscore = 0.5;
$http_response = '{"success": "true", "score": "0.4"}';

$captchaInstance = new captcha\ReCaptcha($recaptcha_url,
$recaptcha_sitekey,
$recaptcha_secretkey,
$recaptcha_minscore);

$error_log = $this->getFunctionMock("captcha", "error_log");
$error_log->expects($this->any())->willReturn("");
$stream_context_create = $this->getFunctionMock("captcha", "stream_context_create");
$stream_context_create->expects($this->once())->willReturn("stream_context_create");
$file_get_contents = $this->getFunctionMock("captcha", "file_get_contents");
$file_get_contents->expects($this->once())->willReturn($http_response);

$_POST["captchaphrase"] = "ABCDE";
$captcha = $captchaInstance->verify_captcha_challenge();
$this->assertEquals('badcaptcha',$captcha, "unexpected return response during verify_captcha_challenge");
}

public function test_verify_captcha_challenge_nocaptcha(): void
{

$recaptcha_url = 'http://127.0.0.1/';
$recaptcha_sitekey = 'sitekey';
$recaptcha_secretkey = 'secret';
$recaptcha_minscore = 0.5;

$captchaInstance = new captcha\ReCaptcha($recaptcha_url,
$recaptcha_sitekey,
$recaptcha_secretkey,
$recaptcha_minscore);

$error_log = $this->getFunctionMock("captcha", "error_log");
$error_log->expects($this->any())->willReturn("");

unset($_POST);
$captcha = $captchaInstance->verify_captcha_challenge();
$this->assertEquals('captcharequired',$captcha, "unexpected return response during verify_captcha_challenge");
}

}

0 comments on commit 28b5749

Please sign in to comment.