diff --git a/README.md b/README.md index a71d3f2..69c8632 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ## Introduction This plugin logs failed login attempts and requires users to go through -a reCAPTCHA verification process when the number of failed attempts go +a reCAPTCHA|hCaptcha verification process when the number of failed attempts go too high. It provides protection against automated attacks. Failed attempts are logged by IP and stored in a database table. @@ -12,8 +12,8 @@ IPs are also released after a certain expire amount of time. ## Installation -**IMPORTANT: This plugin requires reCAPTCHA API keys to work properly.** -
These can be obtained from https://www.google.com/recaptcha. +**IMPORTANT: This plugin requires reCAPTCHA|hCaptcha API keys to work properly.** +
These can be obtained from https://www.google.com/recaptcha or https://dashboard.hcaptcha.com/. #### With Composer @@ -44,15 +44,16 @@ the table `rcguard` accordingly. You may customize the following in the `config.inc.php` file: -- the API version: `v3`, `v2invisible` or `v2`; +- the API version: `v3`, `v2invisible`, `v2` or `v2hcaptcha`; - the v2 widget theme: `light` or `dark`; - the v2 widget size: `normal` or `compact`. -For more information about the widget please check the [documentation about reCAPTCHA][recaptcha-doc]. +For more information about the widget please check the [documentation about reCAPTCHA][recaptcha-doc] +or [documentation about hCaptcha][hcaptcha-doc]. The plugin configuration file has several other options you may configure, please take at look. -Since May 2018, you can define a proxy (anonymous or authenticated) to request the recaptcha widget. +Since May 2018, you can define a proxy (anonymous or authenticated) to request the reCAPTCHA|hCaptcha widget. ## Supported databases @@ -79,12 +80,13 @@ Email: [Diana Soares][email] [email]: mailto:diana.soares@gmail.com [dennylin]: https://github.com/dennylin93 [recaptcha-doc]: https://developers.google.com/recaptcha/intro +[hcaptcha-doc]: https://docs.hcaptcha.com/ ## License This plugin is distributed under the GPL-3.0+ license. -This plugin also contains a PHP library for reCAPTCHA that is -distributed under its own license. See the library file for the exact details. +This plugin also contains PHP libraries for reCAPTCHA and hCaptcha that is +distributed under its own license. See the library files for the exact details. diff --git a/config.inc.php.dist b/config.inc.php.dist index 94616d4..d6843fb 100644 --- a/config.inc.php.dist +++ b/config.inc.php.dist @@ -12,21 +12,22 @@ $config['expire_time'] = 30; // Reset failure count after successfull login (see bratkartoffel/rcguard@670395e) $config['rcguard_reset_after_success'] = true; -// reCAPTCHA API version and url -$config['recaptcha_api_version'] = 'v2'; // v3 | v2 | v2invisible -$config['recaptcha_api_url'] = 'https://www.google.com/recaptcha/api.js'; +// reCAPTCHA|hCaptcha API version and url +$config['recaptcha_api_version'] = 'v2'; // v3 | v2 | v2invisible | v2hcaptcha +$config['recaptcha_api_url'] = 'https://www.google.com/recaptcha/api.js'; // reCAPTCHA +//$config['recaptcha_api_url'] = 'https://js.hcaptcha.com/1/api.js'; // hCaptcha // !!! DEPRECATED - not used anymore !!! //$config['recaptcha_api'] = 'http://www.google.com/recaptcha/api.js'; //$config['recaptcha_api_secure'] = 'https://www.google.com/recaptcha/api.js'; //$config['recaptcha_https'] = true; -// Keys can be obtained from http://www.google.com/recaptcha/ +// Keys can be obtained from http://www.google.com/recaptcha/ or https://dashboard.hcaptcha.com/ -// reCAPTCHA site key +// reCAPTCHA|hCaptcha site key $config['recaptcha_publickey'] = ''; -// reCAPTCHA secret key +// reCAPTCHA|hCaptcha secret key $config['recaptcha_privatekey'] = ''; // Send client IP to Google for reCAPTCHA verification @@ -53,8 +54,9 @@ $config['recaptcha_size'] = 'normal'; // Parameter expansion: // %r - Remote IP // %u - Username -$config['recaptcha_log_success'] = 'Verification succeeded for %u. [%r]'; -$config['recaptcha_log_failure'] = 'Error: Verification failed for %u. [%r]'; +// %v - API version +$config['recaptcha_log_success'] = 'Verification succeeded for %u. [%r] via %v'; +$config['recaptcha_log_failure'] = 'Error: Verification failed for %u. [%r] via %v'; $config['recaptcha_log_unknown'] = 'Error: Unknown log type.'; // Block IPv6 clients based on prefix length diff --git a/lib/hcaptchalib.php b/lib/hcaptchalib.php new file mode 100644 index 0000000..0e4f7cf --- /dev/null +++ b/lib/hcaptchalib.php @@ -0,0 +1,196 @@ +' . self::$signupUrl . ''); + } + + $this->_secret = $secret; + $this->_options = $extra_options; + } + + + /** + * Submit the POST request with the specified parameters. + * + * @param array $params Request parameters + * @return string Body of the hCaptcha response + */ + private function _submit($params) + { + // PHP 5.6.0 changed the way you specify the peer name for SSL context options. + // Using "CN_name" will still work, but it will raise deprecated errors. + $peer_key = version_compare(PHP_VERSION, '5.6.0', '<') ? 'CN_name' : 'peer_name'; + $options = array( + 'http' => array( + 'header' => "Content-type: application/x-www-form-urlencoded\r\n", + 'method' => 'POST', + 'content' => http_build_query($params, '', '&'), + // Force the peer to validate (not needed in 5.6.0+, but still works) + 'verify_peer' => true, + $peer_key => 'hcaptcha.com', + ) + ); + + if ($this->_options) { + $options = self::mergeOptions($options, $this->_options); + } + + // REMEMBER: this is only for this kind of RequestMethod\Post + if (isset($options['http']['proxy']) + && strpos($options['http']['proxy'], 'tcp://') === false) { + $options['http']['proxy'] = 'tcp://' . $options['http']['proxy']; + } + + $context = stream_context_create($options); + return file_get_contents(self::$siteVerifyUrl, false, $context); + } + + /** + * Recursively merge options without appending values. + * + * @param array $opts1 Options array (the default options) + * @param array $opts2 Options array (the given options) + * @return array The merged options + */ + private static function mergeOptions($opts1, $opts2) + { + if (is_array($opts2)) { + foreach ($opts2 as $key => $val) { + $opts1[$key] = ( + is_array($val) && isset($opts1[$key]) && is_array($opts1[$key]) + ? self::mergeOptions($opts1[$key], $val) : $val + ); + } + } + return $opts1; + } + + /** + * Calls the hCaptcha siteverify API to verify whether the user passes + * CAPTCHA test. (hCaptcha version php_1.1.1) + * + * @param string $response The value of 'h-captcha-response' in the submitted form. + * @param string $remoteIp The end user's IP address. + * @param string $sitekey assigned site key + * @return ReCaptchaResponse Response from the service. + */ + public function verify($response, $remoteIp = null, $sitekey = null) + { + if (empty($response)) { // Discard empty solution submissions + return new ReCaptchaResponse(false, array('missing-input')); + } + + $params = array('secret' => $this->_secret, + 'sitekey' => $sitekey, + 'remoteip' => $remoteIp, + 'response' => $response + ); + + $rawResponse = $this->_submit($params); + + return ReCaptchaResponse::fromJson($rawResponse); + } +} + + +/** + * The response returned from the service. + */ +class ReCaptchaResponse +{ + public $success; + public $errorCodes; + + /** + * Constructor. + * + * @param boolean $success + * @param array $errorCodes + */ + function __construct($success, $errorCodes=array()) + { + $this->success = $success; + $this->errorCodes = $errorCodes; + } + + /** + * Build the response from the expected JSON returned by the service. + * + * @param string $json + * @return ReCaptchaResponse + */ + public static function fromJson($json) + { + $responseData = json_decode($json, true); + + if (!$responseData) { + $reCaptchaResponse = new ReCaptchaResponse(false, array('invalid-json')); + } + else if (isset($responseData['success']) && $responseData['success'] == true) { + $reCaptchaResponse = new ReCaptchaResponse(true); + } + else if (isset($responseData['error-codes']) && is_array($responseData['error-codes'])) { + $reCaptchaResponse = new ReCaptchaResponse(false, $responseData['error-codes']); + } + else { + $reCaptchaResponse = new ReCaptchaResponse(false); + } + + return $reCaptchaResponse; + } +} + +?> diff --git a/rcguard.php b/rcguard.php index f6aaf6a..d9507e0 100644 --- a/rcguard.php +++ b/rcguard.php @@ -123,7 +123,14 @@ public function authenticate($args) } $msg = 'rcguard.recaptchaempty'; - $response = rcube_utils::get_input_value('g-recaptcha-response', rcube_utils::INPUT_POST); + + $api_version = $this->rc->config->get('recaptcha_api_version', 'v2'); + $input_value = 'g-recaptcha-response'; + if ($api_version == 'v2hcaptcha') { + $input_value = 'h-captcha-response'; + }; + + $response = rcube_utils::get_input_value($input_value, rcube_utils::INPUT_POST); if ($response) { if ($this->verify_recaptcha($response, $client_ip)) { @@ -310,12 +317,20 @@ private function show_recaptcha_v2invisible() private function show_recaptcha_v2($size = null) { $api = $this->rc->config->get('recaptcha_api_url'); - $src = sprintf('%s?hl=%s', $api, $this->rc->user->language); + $lang = $this->rc->user->language; + $lang_territory_separator_pos = strpos($lang, '_'); + if ($lang_territory_separator_pos > 0) { + // hCaptcha is not supporting 'territory' appendix + $lang = substr($lang, 0, $lang_territory_separator_pos); + }; + $src = sprintf('%s?hl=%s', $api, $lang); $this->include_script($src); + $api_version = $this->rc->config->get('recaptcha_api_version', 'v2'); $html = sprintf( - '
', + ($api_version == 'v2hcaptcha') ? 'h-captcha' : 'g-recaptcha', $this->rc->config->get('recaptcha_publickey'), $this->rc->config->get('recaptcha_theme'), $size ?: $this->rc->config->get('recaptcha_size') @@ -348,10 +363,20 @@ private function verify_recaptcha($response, $client_ip = null) } } - require_once $this->home . '/lib/recaptchalib.php'; + $api_version = $this->rc->config->get('recaptcha_api_version', 'v2'); + if ($api_version == 'v2hcaptcha') { + require_once $this->home . '/lib/hcaptchalib.php'; + } else { + require_once $this->home . '/lib/recaptchalib.php'; + }; $reCaptcha = new ReCaptcha($config->get('recaptcha_privatekey'), $options); - $resp = $reCaptcha->verify($response, $client_ip); + + if ($api_version == 'v2hcaptcha') { + $resp = $reCaptcha->verify($response, $client_ip, $config->get('recaptcha_publickey')); + } else { + $resp = $reCaptcha->verify($response, $client_ip); + }; return $resp != null && $resp->success; } @@ -379,7 +404,8 @@ private function log_recaptcha($log_type, $username) } if (!empty($log_entry)) { - $log_entry = str_replace(['%r', '%u'], [$client_ip, $username], $log_entry); + $api_version = $this->rc->config->get('recaptcha_api_version', 'v2'); + $log_entry = str_replace(['%r', '%u', '%v'], [$client_ip, $username, $api_version], $log_entry); rcube::write_log('rcguard', $log_entry); } }