diff --git a/phpBB/adm/style/acp_captcha.html b/phpBB/adm/style/acp_captcha.html index 4353becd2f..0c9d3bf0af 100644 --- a/phpBB/adm/style/acp_captcha.html +++ b/phpBB/adm/style/acp_captcha.html @@ -1,79 +1,85 @@ - +{% include 'overall_header.html' %} -

{L_ACP_VC_SETTINGS}

+

{{ lang('ACP_VC_SETTINGS') }}

-

{L_ACP_VC_SETTINGS_EXPLAIN}

+

{{ lang('ACP_VC_SETTINGS_EXPLAIN') }}

-

{L_ACP_VC_EXT_GET_MORE}

+

{{ lang('ACP_VC_EXT_GET_MORE') }}

- +{% if ERRORS %}
-

{L_WARNING}

-

{ERROR_MSG}

+

{{ lang('WARNING') }}

+

{{ ERRORS|join('
') }}

- +{% endif %} -
+
-{L_GENERAL_OPTIONS} +{{ lang('GENERAL_OPTIONS') }}
-

{L_VISUAL_CONFIRM_REG_EXPLAIN}
-
-
+

{{ lang('VISUAL_CONFIRM_REG_EXPLAIN') }}
+
+ + +
-

{L_REG_LIMIT_EXPLAIN}
-
+

{{ lang('REG_LIMIT_EXPLAIN') }}
+
-

{L_MAX_LOGIN_ATTEMPTS_EXPLAIN}
-
+

{{ lang('MAX_LOGIN_ATTEMPTS_EXPLAIN') }}
+
-

{L_VISUAL_CONFIRM_POST_EXPLAIN}
-
-
+

{{ lang('VISUAL_CONFIRM_POST_EXPLAIN') }}
+
+ + +
-

{L_VISUAL_CONFIRM_REFRESH_EXPLAIN}
-
-
+

{{ lang('VISUAL_CONFIRM_REFRESH_EXPLAIN') }}
+
+ + +
-{L_AVAILABLE_CAPTCHAS} -
-

{L_CAPTCHA_SELECT_EXPLAIN}
-
-
- -
-

{L_CAPTCHA_CONFIGURE_EXPLAIN}
-
-
- +{{ lang('AVAILABLE_CAPTCHAS') }} +
+

{{ lang('CAPTCHA_SELECT_EXPLAIN') }}
+
{{ FormsSelect(CAPTCHA_SELECT | merge({id: 'captcha_select', onchange: "(document.getElementById('acp_captcha')).submit()"})) }}
+
+ {% if S_CAPTCHA_HAS_CONFIG %} +
+

{{ lang('CAPTCHA_CONFIGURE_EXPLAIN') }}
+
+
+ {% endif %}
- +{% if CAPTCHA_PREVIEW_TPL %}
- {L_PREVIEW} - + {{ lang('PREVIEW') }} + {% include CAPTCHA_PREVIEW_TPL %}
- +{% endif %}
- {L_ACP_SUBMIT_CHANGES} + {{ lang('ACP_SUBMIT_CHANGES') }}

-   -   +   +  

- {S_FORM_TOKEN} + {{ S_FORM_TOKEN }}
- +{% include 'overall_footer.html' %} diff --git a/phpBB/adm/style/captcha_qa_acp.html b/phpBB/adm/style/captcha_qa_acp.html index 26598c7c2b..1118a1f32e 100644 --- a/phpBB/adm/style/captcha_qa_acp.html +++ b/phpBB/adm/style/captcha_qa_acp.html @@ -18,11 +18,13 @@ {L_QUESTIONS} + {% if questions %} {L_QUESTION_TEXT} {L_QUESTION_LANG} {L_ACTION} + {% endif %} @@ -33,6 +35,10 @@ {{ question.QUESTION_LANG }} {{ ICON_EDIT }} {{ ICON_DELETE }} + {% else %} + + {{ lang('QA_NO_QUESTIONS') }} + {% endfor %} diff --git a/phpBB/adm/style/captcha_turnstile_acp.html b/phpBB/adm/style/captcha_turnstile_acp.html new file mode 100644 index 0000000000..a187e1e41b --- /dev/null +++ b/phpBB/adm/style/captcha_turnstile_acp.html @@ -0,0 +1,64 @@ +{% include('overall_header.html') %} + + + +

{{ lang('ACP_VC_SETTINGS') }}

+ +

{{ lang('ACP_VC_SETTINGS_EXPLAIN') }}

+ +
+
+ {{ lang('GENERAL_OPTIONS') }} +
+
+
+ {{ lang('CAPTCHA_TURNSTILE_SITEKEY_EXPLAIN') }} +
+
+
+
+
+
+ {{ lang('CAPTCHA_TURNSTILE_SECRET_EXPLAIN') }} +
+
+
+
+
+ +
{{ lang('CAPTCHA_TURNSTILE_THEME_EXPLAIN') }} +
+
+ {% for theme in CAPTCHA_TURNSTILE_THEMES %} + + {% endfor %} +
+
+
+
+ {{ lang('PREVIEW') }} + {% if PREVIEW %} +
+

{{ lang('WARNING') }}

+

{{ lang('CAPTCHA_PREVIEW_MSG') }}

+
+ {% endif %} + {% include(CAPTCHA_PREVIEW) %} +
+ +
+ {{ lang('ACP_SUBMIT_CHANGES') }} +

+   +   +

+ + + {{ S_FORM_TOKEN }} +
+
+ +{% include('overall_footer.html') %} diff --git a/phpBB/adm/style/captcha_turnstile_acp_demo.html b/phpBB/adm/style/captcha_turnstile_acp_demo.html new file mode 100644 index 0000000000..757d0a4bcb --- /dev/null +++ b/phpBB/adm/style/captcha_turnstile_acp_demo.html @@ -0,0 +1,23 @@ +
+
+
+{% INCLUDEJS U_TURNSTILE_SCRIPT %} + diff --git a/phpBB/config/default/container/services_captcha.yml b/phpBB/config/default/container/services_captcha.yml index 8e9d829b47..ea41fefe80 100644 --- a/phpBB/config/default/container/services_captcha.yml +++ b/phpBB/config/default/container/services_captcha.yml @@ -19,7 +19,11 @@ services: shared: false arguments: - '@config' + - '@dbal.conn' + - '@language' + - '@request' - '@template' + - '@user' - '%core.root_path%' - '%core.php_ext%' calls: @@ -54,3 +58,19 @@ services: - ['set_name', ['core.captcha.plugins.recaptcha_v3']] tags: - { name: captcha.plugins } + + core.captcha.plugins.turnstile: + class: phpbb\captcha\plugins\turnstile + shared: false + arguments: + - '@config' + - '@dbal.conn' + - '@language' + - '@log' + - '@request' + - '@template' + - '@user' + calls: + - ['set_name', ['core.captcha.plugins.turnstile']] + tags: + - { name: captcha.plugins } diff --git a/phpBB/includes/acp/acp_captcha.php b/phpBB/includes/acp/acp_captcha.php index ff9f515d32..e03d2a3901 100644 --- a/phpBB/includes/acp/acp_captcha.php +++ b/phpBB/includes/acp/acp_captcha.php @@ -95,7 +95,7 @@ class acp_captcha add_form_key($form_key); $submit = $request->variable('main_submit', false); - $error = $cfg_array = array(); + $errors = $cfg_array = array(); if ($submit) { @@ -103,13 +103,13 @@ class acp_captcha { $cfg_array[$config_var] = $request->variable($config_var, $options['default']); } - validate_config_vars($config_vars, $cfg_array, $error); + validate_config_vars($config_vars, $cfg_array, $errors); if (!check_form_key($form_key)) { - $error[] = $user->lang['FORM_INVALID']; + $errors[] = $user->lang['FORM_INVALID']; } - if ($error) + if ($errors) { $submit = false; } @@ -128,11 +128,9 @@ class acp_captcha if (isset($captchas['available'][$selected])) { $old_captcha = $factory->get_instance($config['captcha_plugin']); - $old_captcha->uninstall(); + $old_captcha->garbage_collect(); $config->set('captcha_plugin', $selected); - $new_captcha = $factory->get_instance($config['captcha_plugin']); - $new_captcha->install(); $phpbb_log->add('admin', $user->data['user_id'], $user->ip, 'LOG_CONFIG_VISUAL'); } @@ -145,17 +143,24 @@ class acp_captcha } else { - $captcha_select = ''; + $captcha_options = []; foreach ($captchas['available'] as $value => $title) { - $current = ($selected !== false && $value == $selected) ? ' selected="selected"' : ''; - $captcha_select .= ''; + $captcha_options[] = [ + 'value' => $value, + 'label' => $user->lang($title), + 'selected' => $selected !== false && $value == $selected, + ]; } foreach ($captchas['unavailable'] as $value => $title) { - $current = ($selected !== false && $value == $selected) ? ' selected="selected"' : ''; - $captcha_select .= ''; + $captcha_options[] = [ + 'value' => $value, + 'label' => $user->lang($title), + 'selected' => $selected !== false && $value == $selected, + 'class' => 'disabled-option', + ]; } $demo_captcha = $factory->get_instance($selected); @@ -168,8 +173,12 @@ class acp_captcha $template->assign_vars(array( 'CAPTCHA_PREVIEW_TPL' => $demo_captcha->get_demo_template($id), 'S_CAPTCHA_HAS_CONFIG' => $demo_captcha->has_config(), - 'CAPTCHA_SELECT' => $captcha_select, - 'ERROR_MSG' => implode('
', $error), + 'CAPTCHA_SELECT' => [ + 'tag' => 'select', + 'name' => 'select_captcha', + 'options' => $captcha_options, + ], + 'ERRORS' => $errors, 'U_ACTION' => $this->u_action, )); diff --git a/phpBB/includes/constants.php b/phpBB/includes/constants.php index 3304194cf4..825a328d39 100644 --- a/phpBB/includes/constants.php +++ b/phpBB/includes/constants.php @@ -152,9 +152,13 @@ define('FULL_FOLDER_DELETE', -2); define('FULL_FOLDER_HOLD', -1); // Confirm types +/** @deprecated 4.0.0-a1 Replaced by \phpbb\captcha\plugins\confirm_type::REGISTRATION, to be removed in 5.0.0-a1 */ define('CONFIRM_REG', 1); +/** @deprecated 4.0.0-a1 Replaced by \phpbb\captcha\plugins\confirm_type::LOGIN, to be removed in 5.0.0-a1 */ define('CONFIRM_LOGIN', 2); +/** @deprecated 4.0.0-a1 Replaced by \phpbb\captcha\plugins\confirm_type::POST, to be removed in 5.0.0-a1 */ define('CONFIRM_POST', 3); +/** @deprecated 4.0.0-a1 Replaced by \phpbb\captcha\plugins\confirm_type::REPORT, to be removed in 5.0.0-a1 */ define('CONFIRM_REPORT', 4); // Categories - Attachments diff --git a/phpBB/includes/functions_acp.php b/phpBB/includes/functions_acp.php index 646d2f34d5..a3bae23ab5 100644 --- a/phpBB/includes/functions_acp.php +++ b/phpBB/includes/functions_acp.php @@ -203,8 +203,8 @@ function adm_page_footer($copyright_html = true) */ function adm_back_link($u_action) { - global $user; - return '

« ' . $user->lang['BACK_TO_PREV'] . ''; + global $language; + return '

« ' . $language->lang('BACK_TO_PREV') . ''; } /** diff --git a/phpBB/includes/ucp/ucp_register.php b/phpBB/includes/ucp/ucp_register.php index 40b14a9b54..b8d0d78b40 100644 --- a/phpBB/includes/ucp/ucp_register.php +++ b/phpBB/includes/ucp/ucp_register.php @@ -235,8 +235,10 @@ class ucp_register // The CAPTCHA kicks in here. We can't help that the information gets lost on language change. if ($config['enable_confirm']) { - $captcha = $phpbb_container->get('captcha.factory')->get_instance($config['captcha_plugin']); - $captcha->init(CONFIRM_REG); + /** @var \phpbb\captcha\factory $captcha_factory */ + $captcha_factory = $phpbb_container->get('captcha.factory'); + $captcha = $captcha_factory->get_instance($config['captcha_plugin']); + $captcha->init(\phpbb\captcha\plugins\confirm_type::REGISTRATION); } $timezone = $config['board_timezone']; @@ -291,10 +293,9 @@ class ucp_register if ($config['enable_confirm']) { - $vc_response = $captcha->validate($data); - if ($vc_response !== false) + if ($captcha->validate() !== true) { - $error[] = $vc_response; + $error[] = $captcha->get_error(); } if ($config['max_reg_attempts'] && $captcha->get_attempt_count() > $config['max_reg_attempts']) @@ -426,7 +427,7 @@ class ucp_register } // Okay, captcha, your job is done. - if ($config['enable_confirm'] && isset($captcha)) + if ($config['enable_confirm']) { $captcha->reset(); } diff --git a/phpBB/install/schemas/schema_data.sql b/phpBB/install/schemas/schema_data.sql index a160057fd2..b0e553960f 100644 --- a/phpBB/install/schemas/schema_data.sql +++ b/phpBB/install/schemas/schema_data.sql @@ -77,6 +77,9 @@ INSERT INTO phpbb_config (config_name, config_value) VALUES ('captcha_gd_wave', INSERT INTO phpbb_config (config_name, config_value) VALUES ('captcha_gd_x_grid', '25'); INSERT INTO phpbb_config (config_name, config_value) VALUES ('captcha_gd_y_grid', '25'); INSERT INTO phpbb_config (config_name, config_value) VALUES ('captcha_plugin', 'core.captcha.plugins.incomplete'); +INSERT INTO phpbb_config (config_name, config_value) VALUES ('captcha_turnstile_sitekey', ''); +INSERT INTO phpbb_config (config_name, config_value) VALUES ('captcha_turnstile_secret', ''); +INSERT INTO phpbb_config (config_name, config_value) VALUES ('captcha_turnstile_theme', 'light'); INSERT INTO phpbb_config (config_name, config_value) VALUES ('check_attachment_content', '1'); INSERT INTO phpbb_config (config_name, config_value) VALUES ('check_dnsbl', '0'); INSERT INTO phpbb_config (config_name, config_value) VALUES ('chg_passforce', '0'); diff --git a/phpBB/language/en/captcha_qa.php b/phpBB/language/en/captcha_qa.php index 637c4e035e..b883166159 100644 --- a/phpBB/language/en/captcha_qa.php +++ b/phpBB/language/en/captcha_qa.php @@ -61,4 +61,5 @@ $lang = array_merge($lang, array( 'QA_ERROR_MSG' => 'Please fill in all fields and enter at least one answer.', 'QA_LAST_QUESTION' => 'You cannot delete all questions while the plugin is active.', + 'QA_NO_QUESTIONS' => 'There are no questions yet.', )); diff --git a/phpBB/language/en/captcha_turnstile.php b/phpBB/language/en/captcha_turnstile.php new file mode 100644 index 0000000000..687ba4499d --- /dev/null +++ b/phpBB/language/en/captcha_turnstile.php @@ -0,0 +1,53 @@ + +* @license GNU General Public License, version 2 (GPL-2.0) +* +* For full copyright and license information, please see +* the docs/CREDITS.txt file. +* +*/ + +/** +* DO NOT CHANGE +*/ +if (!defined('IN_PHPBB')) +{ + exit; +} + +if (empty($lang) || !is_array($lang)) +{ + $lang = []; +} + +// DEVELOPERS PLEASE NOTE +// +// All language files should use UTF-8 as their encoding and the files must not contain a BOM. +// +// Placeholders can now contain order information, e.g. instead of +// 'Page %s of %s' you can (and should) write 'Page %1$s of %2$s', this allows +// translators to re-order the output of data while ensuring it remains correct +// +// You do not need this where single placeholders are used, e.g. 'Message %d' is fine +// equally where a string contains only two placeholders which are used to wrap text +// in a url you again do not need to specify an order e.g., 'Click %sHERE%s' is fine + +$lang = array_merge($lang, [ + 'CAPTCHA_TURNSTILE' => 'Turnstile', + 'CAPTCHA_TURNSTILE_INCORRECT' => 'The solution you provided was incorrect', + 'CAPTCHA_TURNSTILE_NOSCRIPT' => 'Please enable JavaScript in your browser to load the challenge.', + 'CAPTCHA_TURNSTILE_NOT_AVAILABLE' => 'In order to use Turnstile you must create a Cloudflare account.', + 'CAPTCHA_TURNSTILE_SECRET' => 'Secret key', + 'CAPTCHA_TURNSTILE_SECRET_EXPLAIN' => 'Your Turnstile secret key. The secret key can be retrieved from your Cloudflare dashboard.', + 'CAPTCHA_TURNSTILE_SITEKEY' => 'Sitekey', + 'CAPTCHA_TURNSTILE_SITEKEY_EXPLAIN' => 'Your Turnstile sitekey. The sitekey can be retrieved from your Cloudflare dashboard.', + 'CAPTCHA_TURNSTILE_THEME' => 'Widget theme', + 'CAPTCHA_TURNSTILE_THEME_EXPLAIN' => 'The theme of the CAPTCHA widget. By default, light will be used. Other possibilities are dark and auto, which respects the user’s preference.', + 'CAPTCHA_TURNSTILE_THEME_AUTO' => 'Auto', + 'CAPTCHA_TURNSTILE_THEME_DARK' => 'Dark', + 'CAPTCHA_TURNSTILE_THEME_LIGHT' => 'Light', +]); diff --git a/phpBB/language/en/composer.json b/phpBB/language/en/composer.json index 7402e5efa5..cd6bcdb581 100644 --- a/phpBB/language/en/composer.json +++ b/phpBB/language/en/composer.json @@ -26,6 +26,7 @@ "direction": "ltr", "user-lang": "en-gb", "plural-rule": 1, - "recaptcha-lang": "en-GB" + "recaptcha-lang": "en-GB", + "turnstile-lang": "en" } } diff --git a/phpBB/phpbb/auth/provider/db.php b/phpBB/phpbb/auth/provider/db.php index ae9d968076..5f8ebd6ac3 100644 --- a/phpBB/phpbb/auth/provider/db.php +++ b/phpBB/phpbb/auth/provider/db.php @@ -176,9 +176,8 @@ class db extends base // Every auth module is able to define what to do by itself... if ($show_captcha) { - $captcha->init(CONFIRM_LOGIN); - $vc_response = $captcha->validate($row); - if ($vc_response) + $captcha->init(\phpbb\captcha\plugins\confirm_type::LOGIN); + if ($captcha->validate() !== true) { return array( 'status' => LOGIN_ERROR_ATTEMPTS, diff --git a/phpBB/phpbb/captcha/factory.php b/phpBB/phpbb/captcha/factory.php index 2e75ce8667..fc974569bb 100644 --- a/phpBB/phpbb/captcha/factory.php +++ b/phpBB/phpbb/captcha/factory.php @@ -13,6 +13,9 @@ namespace phpbb\captcha; +use phpbb\captcha\plugins\legacy_wrapper; +use phpbb\captcha\plugins\plugin_interface; + class factory { /** @@ -41,11 +44,17 @@ class factory * Return a new instance of a given plugin * * @param $name - * @return object|null + * @return plugin_interface */ - public function get_instance($name) + public function get_instance($name): plugin_interface { - return $this->container->get($name); + $captcha = $this->container->get($name); + if ($captcha instanceof plugin_interface) + { + return $captcha; + } + + return new legacy_wrapper($captcha); } /** @@ -56,7 +65,7 @@ class factory function garbage_collect($name) { $captcha = $this->get_instance($name); - $captcha->garbage_collect(0); + $captcha->garbage_collect(); } /** diff --git a/phpBB/phpbb/captcha/plugins/base.php b/phpBB/phpbb/captcha/plugins/base.php new file mode 100644 index 0000000000..a30e679731 --- /dev/null +++ b/phpBB/phpbb/captcha/plugins/base.php @@ -0,0 +1,256 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +namespace phpbb\captcha\plugins; + +use phpbb\config\config; +use phpbb\db\driver\driver_interface; +use phpbb\language\language; +use phpbb\request\request_interface; +use phpbb\user; + +abstract class base implements plugin_interface +{ + /** @var config */ + protected config $config; + + /** @var driver_interface */ + protected driver_interface $db; + + /** @var language */ + protected language $language; + + /** @var request_interface */ + protected request_interface $request; + + /** @var user */ + protected user $user; + + /** @var int Attempts at solving the CAPTCHA */ + protected int $attempts = 0; + + /** @var string Stored random CAPTCHA code */ + protected string $code = ''; + + /** @var bool Resolved state of captcha */ + protected bool $solved = false; + + /** @var string User supplied confirm code */ + protected string $confirm_code = ''; + + /** @var string Confirm id hash */ + protected string $confirm_id = ''; + + /** @var confirm_type Confirmation type */ + protected confirm_type $type = confirm_type::UNDEFINED; + + /** @var string Last error message */ + protected string $last_error = ''; + + /** + * Constructor for abstract captcha base class + * + * @param config $config + * @param driver_interface $db + * @param language $language + * @param request_interface $request + * @param user $user + */ + public function __construct(config $config, driver_interface $db, language $language, request_interface $request, user $user) + { + $this->config = $config; + $this->db = $db; + $this->language = $language; + $this->request = $request; + $this->user = $user; + } + + /** + * {@inheritDoc} + */ + public function init(confirm_type $type): void + { + $this->confirm_id = $this->request->variable('confirm_id', ''); + $this->confirm_code = $this->request->variable('confirm_code', ''); + $this->type = $type; + + if (empty($this->confirm_id) || !$this->load_confirm_data()) + { + // we have no confirm ID, better get ready to display something + $this->generate_confirm_data(); + } + } + + /** + * {@inheritDoc} + */ + public function validate(): bool + { + if ($this->confirm_id && hash_equals($this->code, $this->confirm_code)) + { + $this->solved = true; + return true; + } + + $this->increment_attempts(); + $this->last_error = $this->language->lang('CONFIRM_CODE_WRONG'); + return false; + } + + /** + * {@inheritDoc} + */ + public function reset(): void + { + $sql = 'DELETE FROM ' . CONFIRM_TABLE . " + WHERE session_id = '" . $this->db->sql_escape($this->user->session_id) . "' + AND confirm_type = " . $this->type->value; + $this->db->sql_query($sql); + + $this->generate_confirm_data(); + } + + /** + * {@inheritDoc} + */ + public function get_attempt_count(): int + { + return $this->attempts; + } + + /** + * Look up attempts from confirm table + */ + protected function load_confirm_data(): bool + { + $sql = 'SELECT code, attempts + FROM ' . CONFIRM_TABLE . " + WHERE confirm_id = '" . $this->db->sql_escape($this->confirm_id) . "' + AND session_id = '" . $this->db->sql_escape($this->user->session_id) . "' + AND confirm_type = " . $this->type->value; + $result = $this->db->sql_query($sql); + $row = $this->db->sql_fetchrow($result); + $this->db->sql_freeresult($result); + + if ($row) + { + $this->attempts = $row['attempts']; + $this->code = $row['code']; + + return true; + } + + return false; + } + + /** + * Generate confirm data for tracking attempts + * + * @return void + */ + protected function generate_confirm_data(): void + { + $this->code = gen_rand_string_friendly(CAPTCHA_MAX_CHARS); + $this->confirm_id = md5(unique_id()); + $this->attempts = 0; + + $sql = 'INSERT INTO ' . CONFIRM_TABLE . ' ' . $this->db->sql_build_array('INSERT', array( + 'confirm_id' => $this->confirm_id, + 'session_id' => (string) $this->user->session_id, + 'confirm_type' => $this->type->value, + 'code' => $this->code, + )); + $this->db->sql_query($sql); + } + + /** + * Increment number of attempts for confirm ID and session + * + * @return void + */ + protected function increment_attempts(): void + { + $sql = 'UPDATE ' . CONFIRM_TABLE . " + SET attempts = attempts + 1 + WHERE confirm_id = '{$this->db->sql_escape($this->confirm_id)}' + AND session_id = '{$this->db->sql_escape($this->user->session_id)}'"; + $this->db->sql_query($sql); + + $this->attempts++; + } + + /** + * {@inheritDoc} + */ + public function get_hidden_fields(): array + { + return [ + 'confirm_id' => $this->confirm_id, + 'confirm_code' => $this->solved === true ? $this->confirm_code : '', + ]; + } + + /** + * {@inheritDoc} + */ + public function is_solved(): bool + { + return $this->solved; + } + + /** + * {@inheritDoc} + */ + public function get_error(): string + { + return $this->last_error; + } + + /** + * @inheritDoc + */ + public function garbage_collect(confirm_type $confirm_type = confirm_type::UNDEFINED): void + { + $sql = 'SELECT DISTINCT c.session_id + FROM ' . CONFIRM_TABLE . ' c + LEFT JOIN ' . SESSIONS_TABLE . ' s ON (c.session_id = s.session_id) + WHERE s.session_id IS NULL' . + ((empty($confirm_type)) ? '' : ' AND c.confirm_type = ' . $confirm_type->value); + $result = $this->db->sql_query($sql); + + if ($row = $this->db->sql_fetchrow($result)) + { + $sql_in = []; + do + { + $sql_in[] = (string) $row['session_id']; + } + while ($row = $this->db->sql_fetchrow($result)); + + if (count($sql_in)) + { + $sql = 'DELETE FROM ' . CONFIRM_TABLE . ' + WHERE ' . $this->db->sql_in_set('session_id', $sql_in); + $this->db->sql_query($sql); + } + } + $this->db->sql_freeresult($result); + } + + /** + * {@inheritDoc} + */ + public function acp_page(mixed $id, mixed $module): void + { + } +} diff --git a/phpBB/phpbb/captcha/plugins/captcha_abstract.php b/phpBB/phpbb/captcha/plugins/captcha_abstract.php index 012e28c987..c6978a1bf1 100644 --- a/phpBB/phpbb/captcha/plugins/captcha_abstract.php +++ b/phpBB/phpbb/captcha/plugins/captcha_abstract.php @@ -179,16 +179,6 @@ abstract class captcha_abstract $db->sql_freeresult($result); } - function uninstall() - { - $this->garbage_collect(0); - } - - function install() - { - return; - } - function validate() { global $user; diff --git a/phpBB/phpbb/captcha/plugins/confirm_type.php b/phpBB/phpbb/captcha/plugins/confirm_type.php new file mode 100644 index 0000000000..e3e11edf77 --- /dev/null +++ b/phpBB/phpbb/captcha/plugins/confirm_type.php @@ -0,0 +1,25 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +namespace phpbb\captcha\plugins; + +/** + * Confirmation types for CAPTCHA plugins + */ +enum confirm_type: int { + case UNDEFINED = 0; + case REGISTRATION = 1; + case LOGIN = 2; + case POST = 3; + case REPORT = 4; +} diff --git a/phpBB/phpbb/captcha/plugins/incomplete.php b/phpBB/phpbb/captcha/plugins/incomplete.php index ec3376c0a5..07998a7125 100644 --- a/phpBB/phpbb/captcha/plugins/incomplete.php +++ b/phpBB/phpbb/captcha/plugins/incomplete.php @@ -14,25 +14,34 @@ namespace phpbb\captcha\plugins; use phpbb\config\config; -use phpbb\exception\runtime_exception; +use phpbb\db\driver\driver_interface; +use phpbb\language\language; +use phpbb\request\request_interface; use phpbb\template\template; +use phpbb\user; -class incomplete extends captcha_abstract +class incomplete extends base { /** * Constructor for incomplete captcha * * @param config $config + * @param driver_interface $db + * @param language $language + * @param request_interface $request * @param template $template + * @param user $user * @param string $phpbb_root_path * @param string $phpEx */ - public function __construct(protected config $config, protected template $template, - protected string $phpbb_root_path, protected string $phpEx) - {} + public function __construct(config $config, driver_interface $db, language $language, request_interface $request, + protected template $template, user $user, protected string $phpbb_root_path, protected string $phpEx) + { + parent::__construct($config, $db, $language, $request, $user); + } /** - * @return bool True if captcha is available, false if not + * {@inheritDoc} */ public function is_available(): bool { @@ -40,70 +49,45 @@ class incomplete extends captcha_abstract } /** - * Dummy implementation, not supported by this captcha - * - * @throws runtime_exception - * @return void + * {@inheritDoc} */ - public function get_generator_class(): void + public function has_config(): bool { - throw new runtime_exception('NO_GENERATOR_CLASS'); + return false; } /** - * Get CAPTCHA name language variable - * - * @return string Language variable + * {@inheritDoc} */ - public static function get_name(): string + public function get_name(): string { return 'CAPTCHA_INCOMPLETE'; } /** - * Init CAPTCHA - * - * @param int $type CAPTCHA type - * @return void + * {@inheritDoc} */ - public function init($type) + public function set_name(string $name): void { } /** - * Execute demo - * - * @return void + * {@inheritDoc} */ - public function execute_demo() + public function init(confirm_type $type): void { } /** - * Execute CAPTCHA - * - * @return void + * {@inheritDoc} */ - public function execute() - { - } - - /** - * Get template data for demo - * - * @param int|string $id ACP module ID - * - * @return string Demo template file name - */ - public function get_demo_template($id): string + public function get_demo_template(): string { return ''; } /** - * Get template data for CAPTCHA - * - * @return string CAPTCHA template file name + * {@inheritDoc} */ public function get_template(): string { @@ -118,9 +102,7 @@ class incomplete extends captcha_abstract } /** - * Validate CAPTCHA - * - * @return false Incomplete CAPTCHA will never validate + * {@inheritDoc} */ public function validate(): bool { @@ -128,12 +110,26 @@ class incomplete extends captcha_abstract } /** - * Check whether CAPTCHA is solved - * - * @return false Incomplete CAPTCHA will never be solved + * {@inheritDoc} + */ + public function get_error(): string + { + return ''; + } + + /** + * {@inheritDoc} */ public function is_solved(): bool { return false; } + + /** + * {@inheritDoc} + */ + public function get_attempt_count(): int + { + return 0; + } } diff --git a/phpBB/phpbb/captcha/plugins/legacy_wrapper.php b/phpBB/phpbb/captcha/plugins/legacy_wrapper.php new file mode 100644 index 0000000000..aaabddb48b --- /dev/null +++ b/phpBB/phpbb/captcha/plugins/legacy_wrapper.php @@ -0,0 +1,221 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +namespace phpbb\captcha\plugins; + +class legacy_wrapper implements plugin_interface +{ + /** @var object Legacy CAPTCHA instance, should implement functionality as required in phpBB 3.3 */ + private $legacy_captcha; + + /** @var string Last error */ + private string $last_error; + + /** + * Constructor for legacy CAPTCHA wrapper + * + * @param object $legacy_captcha + */ + public function __construct(object $legacy_captcha) + { + $this->legacy_captcha = $legacy_captcha; + } + + /** + * {@inheritDoc} + */ + public function is_available(): bool + { + if (method_exists($this->legacy_captcha, 'is_available')) + { + return $this->legacy_captcha->is_available(); + } + + return false; + } + + /** + * {@inheritDoc} + */ + public function has_config(): bool + { + if (method_exists($this->legacy_captcha, 'has_config')) + { + return $this->legacy_captcha->has_config(); + } + + return false; + } + + /** + * {@inheritDoc} + */ + public function get_name(): string + { + if (method_exists($this->legacy_captcha, 'get_name')) + { + return $this->legacy_captcha->get_name(); + } + + return ''; + } + + /** + * {@inheritDoc} + */ + public function set_name(string $name): void + { + if (method_exists($this->legacy_captcha, 'set_name')) + { + $this->legacy_captcha->set_name($name); + } + } + + /** + * {@inheritDoc} + */ + public function init(confirm_type $type): void + { + if (method_exists($this->legacy_captcha, 'init')) + { + $this->legacy_captcha->init($type->value); + } + } + + /** + * {@inheritDoc} + */ + public function get_hidden_fields(): array + { + if (method_exists($this->legacy_captcha, 'get_hidden_fields')) + { + return $this->legacy_captcha->get_hidden_fields(); + } + + return []; + } + + /** + * {@inheritDoc} + */ + public function validate(): bool + { + if (method_exists($this->legacy_captcha, 'validate')) + { + $error = $this->legacy_captcha->validate(); + if ($error) + { + $this->last_error = $error; + return false; + } + + return true; + } + + return false; + } + + /** + * {@inheritDoc} + */ + public function get_error(): string + { + return $this->last_error; + } + + /** + * {@inheritDoc} + */ + public function is_solved(): bool + { + if (method_exists($this->legacy_captcha, 'is_solved')) + { + return $this->legacy_captcha->is_solved(); + } + + return false; + } + + /** + * {@inheritDoc} + */ + public function reset(): void + { + if (method_exists($this->legacy_captcha, 'reset')) + { + $this->legacy_captcha->reset(); + } + } + + /** + * {@inheritDoc} + */ + public function get_attempt_count(): int + { + if (method_exists($this->legacy_captcha, 'get_attempt_count')) + { + return $this->legacy_captcha->get_attempt_count(); + } + + // Ensure this is deemed as too many attempts + return PHP_INT_MAX; + } + + /** + * {@inheritDoc} + */ + public function get_template(): string + { + if (method_exists($this->legacy_captcha, 'get_template')) + { + return $this->legacy_captcha->get_template(); + } + + return ''; + } + + /** + * {@inheritDoc} + */ + public function get_demo_template(): string + { + if (method_exists($this->legacy_captcha, 'get_demo_template')) + { + return $this->legacy_captcha->get_demo_template(0); + } + + return ''; + } + + /** + * {@inheritDoc} + */ + public function garbage_collect(confirm_type $confirm_type = confirm_type::UNDEFINED): void + { + if (method_exists($this->legacy_captcha, 'garbage_collect')) + { + $this->legacy_captcha->garbage_collect($confirm_type->value); + } + } + + /** + * {@inheritDoc} + */ + public function acp_page(mixed $id, mixed $module): void + { + if (method_exists($this->legacy_captcha, 'acp_page')) + { + $this->legacy_captcha->acp_page($id, $module); + } + } +} diff --git a/phpBB/phpbb/captcha/plugins/plugin_interface.php b/phpBB/phpbb/captcha/plugins/plugin_interface.php new file mode 100644 index 0000000000..41483e45a5 --- /dev/null +++ b/phpBB/phpbb/captcha/plugins/plugin_interface.php @@ -0,0 +1,126 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +namespace phpbb\captcha\plugins; + +interface plugin_interface +{ + /** + * Check if the plugin is available + * + * @return bool True if the plugin is available, false if not + */ + public function is_available(): bool; + + /** + * Check if the plugin has a configuration + * + * @return bool True if the plugin has a configuration, false if not + */ + public function has_config(): bool; + + /** + * Get the name of the plugin, should be language variable + * + * @return string + */ + public function get_name(): string; + + /** + * Set the service name of the plugin + * + * @param string $name + */ + public function set_name(string $name): void; + + /** + * Display the captcha for the specified type + * + * @param confirm_type $type Type of captcha, should be one of the CONFIRMATION_* constants + * @return void + */ + public function init(confirm_type $type): void; + + /** + * Get hidden form fields for this captcha plugin + * + * @return array Hidden form fields + */ + public function get_hidden_fields(): array; + + /** + * Validate the captcha with the given request data + * + * @return bool True if request data was valid captcha reply, false if not + */ + public function validate(): bool; + + /** + * Get error string from captcha + * + * @return string Error string, empty string if there is no error + */ + public function get_error(): string; + + /** + * Return whether captcha was solved + * + * @return bool True if captcha was solved, false if not + */ + public function is_solved(): bool; + + /** + * Reset captcha state, e.g. after checking if it's valid + * + * @return void + */ + public function reset(): void; + + /** + * Get attempt count for this captcha and user + * + * @return int Number of attempts + */ + public function get_attempt_count(): int; + + /** + * Get template filename for captcha + * + * @return string Template file name + */ + public function get_template(): string; + + /** + * Get template filename for demo + * + * @return string Demo template file name + */ + public function get_demo_template(): string; + + /** + * Garbage collect captcha plugin + * + * @param confirm_type $confirm_type Confirm type to garbage collect, defaults to all (0) + * @return void + */ + public function garbage_collect(confirm_type $confirm_type = confirm_type::UNDEFINED): void; + + /** + * Display acp page + * + * @param mixed $id ACP module id + * @param mixed $module ACP module name + * @return void + */ + public function acp_page(mixed $id, mixed $module): void; +} diff --git a/phpBB/phpbb/captcha/plugins/qa.php b/phpBB/phpbb/captcha/plugins/qa.php index b9bfac33f1..6e09aaaf13 100644 --- a/phpBB/phpbb/captcha/plugins/qa.php +++ b/phpBB/phpbb/captcha/plugins/qa.php @@ -40,7 +40,7 @@ class qa protected $service_name; /** @var int Question ID */ - protected $question = -1; + private $question = -1; /** * Constructor @@ -323,71 +323,6 @@ class qa $db->sql_freeresult($result); } - /** - * API function - we don't drop the tables here, as that would cause the loss of all entered questions. - */ - function uninstall() - { - $this->garbage_collect(0); - } - - /** - * API function - set up shop - */ - function install() - { - global $phpbb_container; - - $db_tool = $phpbb_container->get('dbal.tools'); - $schemas = array( - $this->table_captcha_questions => array ( - 'COLUMNS' => array( - 'question_id' => array('UINT', null, 'auto_increment'), - 'strict' => array('BOOL', 0), - 'lang_id' => array('UINT', 0), - 'lang_iso' => array('VCHAR:30', ''), - 'question_text' => array('TEXT_UNI', ''), - ), - 'PRIMARY_KEY' => 'question_id', - 'KEYS' => array( - 'lang' => array('INDEX', 'lang_iso'), - ), - ), - $this->table_captcha_answers => array ( - 'COLUMNS' => array( - 'question_id' => array('UINT', 0), - 'answer_text' => array('STEXT_UNI', ''), - ), - 'KEYS' => array( - 'qid' => array('INDEX', 'question_id'), - ), - ), - $this->table_qa_confirm => array ( - 'COLUMNS' => array( - 'session_id' => array('CHAR:32', ''), - 'confirm_id' => array('CHAR:32', ''), - 'lang_iso' => array('VCHAR:30', ''), - 'question_id' => array('UINT', 0), - 'attempts' => array('UINT', 0), - 'confirm_type' => array('USINT', 0), - ), - 'KEYS' => array( - 'session_id' => array('INDEX', 'session_id'), - 'lookup' => array('INDEX', array('confirm_id', 'session_id', 'lang_iso')), - ), - 'PRIMARY_KEY' => 'confirm_id', - ), - ); - - foreach ($schemas as $table => $schema) - { - if (!$db_tool->sql_table_exists($table)) - { - $db_tool->sql_create_table($table, $schema); - } - } - } - /** * API function - see what has to be done to validate */ @@ -647,11 +582,6 @@ class qa $user->add_lang('acp/board'); $user->add_lang('captcha_qa'); - if (!self::is_installed()) - { - $this->install(); - } - $module->tpl_name = 'captcha_qa_acp'; $module->page_title = 'ACP_VC_SETTINGS'; $form_key = 'acp_captcha'; diff --git a/phpBB/phpbb/captcha/plugins/recaptcha.php b/phpBB/phpbb/captcha/plugins/recaptcha.php index ef30baaa9b..1c080ba67b 100644 --- a/phpBB/phpbb/captcha/plugins/recaptcha.php +++ b/phpBB/phpbb/captcha/plugins/recaptcha.php @@ -179,16 +179,6 @@ class recaptcha extends captcha_abstract return $hidden_fields; } - function uninstall() - { - $this->garbage_collect(0); - } - - function install() - { - return; - } - function validate() { if (!parent::validate()) diff --git a/phpBB/phpbb/captcha/plugins/turnstile.php b/phpBB/phpbb/captcha/plugins/turnstile.php new file mode 100644 index 0000000000..9a235edc3b --- /dev/null +++ b/phpBB/phpbb/captcha/plugins/turnstile.php @@ -0,0 +1,287 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +namespace phpbb\captcha\plugins; + +use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; +use phpbb\config\config; +use phpbb\db\driver\driver_interface; +use phpbb\language\language; +use phpbb\log\log_interface; +use phpbb\request\request_interface; +use phpbb\template\template; +use phpbb\user; + +class turnstile extends base +{ + /** @var string URL to cloudflare turnstile API javascript */ + private const SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; + + /** @var string API endpoint for turnstile verification */ + private const VERIFY_ENDPOINT = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; + + /** @var Client */ + protected Client $client; + + /** @var language */ + protected language $language; + + /** @var log_interface */ + protected log_interface $log; + + /** @var template */ + protected template $template; + + /** @var string Service name */ + protected string $service_name = ''; + + /** @var array|string[] Supported themes for Turnstile CAPTCHA */ + protected static array $supported_themes = [ + 'light', + 'dark', + 'auto' + ]; + + /** + * Constructor for turnstile captcha plugin + * + * @param config $config + * @param driver_interface $db + * @param language $language + * @param log_interface $log + * @param request_interface $request + * @param template $template + * @param user $user + */ + public function __construct(config $config, driver_interface $db, language $language, log_interface $log, request_interface $request, template $template, user $user) + { + parent::__construct($config, $db, $language, $request, $user); + + $this->language = $language; + $this->log = $log; + $this->template = $template; + } + + /** + * {@inheritDoc} + */ + public function is_available(): bool + { + $this->init($this->type); + + return !empty($this->config->offsetGet('captcha_turnstile_sitekey')) + && !empty($this->config->offsetGet('captcha_turnstile_secret')); + } + + /** + * {@inheritDoc} + */ + public function has_config(): bool + { + return true; + } + + /** + * {@inheritDoc} + */ + public function get_name(): string + { + return 'CAPTCHA_TURNSTILE'; + } + + /** + * {@inheritDoc} + */ + public function set_name(string $name): void + { + $this->service_name = $name; + } + + /** + * {@inheritDoc} + */ + public function init(confirm_type $type): void + { + parent::init($type); + + $this->language->add_lang('captcha_turnstile'); + } + + /** + * {@inheritDoc} + */ + public function validate(): bool + { + if (parent::validate()) + { + return true; + } + + $turnstile_response = $this->request->variable('cf-turnstile-response', ''); + if (!$turnstile_response) + { + // Return without checking against server without a turnstile response + return false; + } + + // Retrieve form data for verification + $form_data = [ + 'secret' => $this->config['captcha_turnstile_secret'], + 'response' => $turnstile_response, + 'remoteip' => $this->user->ip, + ]; + + // Create guzzle client + $client = $this->get_client(); + + // Check captcha with turnstile API + try + { + $response = $client->request('POST', self::VERIFY_ENDPOINT, [ + 'form_params' => $form_data, + ]); + } + catch (GuzzleException) + { + // Something went wrong during the request to Cloudflare, assume captcha was bad + $this->solved = false; + return false; + } + + // Decode the JSON response + $result = json_decode($response->getBody(), true); + + // Check if the response indicates success + if (isset($result['success']) && $result['success'] === true) + { + $this->solved = true; + $this->confirm_code = $this->code; + return true; + } + else + { + $this->last_error = $this->language->lang('CAPTCHA_TURNSTILE_INCORRECT'); + return false; + } + } + + /** + * Get Guzzle client + * + * @return Client + */ + protected function get_client(): Client + { + if (!isset($this->client)) + { + $this->client = new Client(); + } + + return $this->client; + } + + /** + * {@inheritDoc} + */ + public function get_template(): string + { + if ($this->is_solved()) + { + return ''; + } + + $this->template->assign_vars([ + 'S_TURNSTILE_AVAILABLE' => $this->is_available(), + 'TURNSTILE_SITEKEY' => $this->config->offsetGet('captcha_turnstile_sitekey'), + 'TURNSTILE_THEME' => $this->config->offsetGet('captcha_turnstile_theme'), + 'U_TURNSTILE_SCRIPT' => self::SCRIPT_URL, + 'CONFIRM_TYPE_REGISTRATION' => $this->type->value, + ]); + + return 'captcha_turnstile.html'; + } + + /** + * {@inheritDoc} + */ + public function get_demo_template(): string + { + $this->template->assign_vars([ + 'TURNSTILE_THEME' => $this->config->offsetGet('captcha_turnstile_theme'), + 'U_TURNSTILE_SCRIPT' => self::SCRIPT_URL, + ]); + + return 'captcha_turnstile_acp_demo.html'; + } + + /** + * {@inheritDoc} + */ + public function acp_page(mixed $id, mixed $module): void + { + $captcha_vars = [ + 'captcha_turnstile_sitekey' => 'CAPTCHA_TURNSTILE_SITEKEY', + 'captcha_turnstile_secret' => 'CAPTCHA_TURNSTILE_SECRET', + ]; + + $module->tpl_name = 'captcha_turnstile_acp'; + $module->page_title = 'ACP_VC_SETTINGS'; + $form_key = 'acp_captcha'; + add_form_key($form_key); + + $submit = $this->request->is_set_post('submit'); + + if ($submit && check_form_key($form_key)) + { + $captcha_vars = array_keys($captcha_vars); + foreach ($captcha_vars as $captcha_var) + { + $value = $this->request->variable($captcha_var, ''); + if ($value) + { + $this->config->set($captcha_var, $value); + } + } + + $captcha_theme = $this->request->variable('captcha_turnstile_theme', self::$supported_themes[0]); + if (in_array($captcha_theme, self::$supported_themes)) + { + $this->config->set('captcha_turnstile_theme', $captcha_theme); + } + + $this->log->add('admin', $this->user->data['user_id'], $this->user->ip, 'LOG_CONFIG_VISUAL'); + trigger_error($this->language->lang('CONFIG_UPDATED') . adm_back_link($module->u_action)); + } + else if ($submit) + { + trigger_error($this->language->lang('FORM_INVALID') . adm_back_link($module->u_action)); + } + else + { + foreach ($captcha_vars as $captcha_var => $template_var) + { + $var = $this->request->is_set($captcha_var) ? $this->request->variable($captcha_var, '') : $this->config->offsetGet($captcha_var); + $this->template->assign_var($template_var, $var); + } + + $this->template->assign_vars(array( + 'CAPTCHA_PREVIEW' => $this->get_demo_template(), + 'CAPTCHA_NAME' => $this->service_name, + 'CAPTCHA_TURNSTILE_THEME' => $this->config->offsetGet('captcha_turnstile_theme'), + 'CAPTCHA_TURNSTILE_THEMES' => self::$supported_themes, + 'U_ACTION' => $module->u_action, + )); + } + } +} diff --git a/phpBB/phpbb/db/migration/data/v400/qa_captcha.php b/phpBB/phpbb/db/migration/data/v400/qa_captcha.php new file mode 100644 index 0000000000..69cca2dc15 --- /dev/null +++ b/phpBB/phpbb/db/migration/data/v400/qa_captcha.php @@ -0,0 +1,89 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +namespace phpbb\db\migration\data\v400; + +use phpbb\db\migration\migration; + +class qa_captcha extends migration +{ + public function effectively_installed(): bool + { + return $this->db_tools->sql_table_exists($this->tables['captcha_qa_questions']) + && $this->db_tools->sql_table_exists($this->tables['captcha_qa_answers']) + && $this->db_tools->sql_table_exists($this->tables['captcha_qa_confirm']); + } + + public static function depends_on(): array + { + return [ + '\phpbb\db\migration\data\v400\dev', + ]; + } + + public function update_schema(): array + { + return [ + 'add_tables' => [ + $this->tables['captcha_qa_questions'] => [ + 'COLUMNS' => [ + 'question_id' => ['UINT', null, 'auto_increment'], + 'strict' => ['BOOL', 0], + 'lang_id' => ['UINT', 0], + 'lang_iso' => ['VCHAR:30', ''], + 'question_text' => ['TEXT_UNI', ''], + ], + 'PRIMARY_KEY' => 'question_id', + 'KEYS' => [ + 'lang' => ['INDEX', 'lang_iso'], + ], + ], + $this->tables['captcha_qa_answers'] => [ + 'COLUMNS' => [ + 'question_id' => ['UINT', 0], + 'answer_text' => ['STEXT_UNI', ''], + ], + 'KEYS' => [ + 'qid' => ['INDEX', 'question_id'], + ], + ], + $this->tables['captcha_qa_confirm'] => [ + 'COLUMNS' => [ + 'session_id' => ['CHAR:32', ''], + 'confirm_id' => ['CHAR:32', ''], + 'lang_iso' => ['VCHAR:30', ''], + 'question_id' => ['UINT', 0], + 'attempts' => ['UINT', 0], + 'confirm_type' => ['USINT', 0], + ], + 'KEYS' => [ + 'session_id' => ['INDEX', 'session_id'], + 'lookup' => ['INDEX', ['confirm_id', 'session_id', 'lang_iso']], + ], + 'PRIMARY_KEY' => 'confirm_id', + ], + ], + ]; + } + + public function revert_schema(): array + { + return [ + 'drop_tables' => [ + $this->tables['captcha_qa_questions'], + $this->tables['captcha_qa_answers'], + $this->tables['captcha_qa_confirm'] + ], + ]; + } +} diff --git a/phpBB/phpbb/db/migration/data/v400/turnstile_captcha.php b/phpBB/phpbb/db/migration/data/v400/turnstile_captcha.php new file mode 100644 index 0000000000..77e921c090 --- /dev/null +++ b/phpBB/phpbb/db/migration/data/v400/turnstile_captcha.php @@ -0,0 +1,51 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +namespace phpbb\db\migration\data\v400; + +use phpbb\db\migration\migration; + +class turnstile_captcha extends migration +{ + public function effectively_installed(): bool + { + return $this->config->offsetExists('captcha_turnstile_sitekey') + && $this->config->offsetExists('captcha_turnstile_secret') + && $this->config->offsetExists('captcha_turnstile_theme'); + } + + public static function depends_on(): array + { + return [ + '\phpbb\db\migration\data\v400\dev', + ]; + } + + public function update_data(): array + { + return [ + ['config.add', ['captcha_turnstile_sitekey', '']], + ['config.add', ['captcha_turnstile_secret', '']], + ['config.add', ['captcha_turnstile_theme', 'light']], + ]; + } + + public function revert_data(): array + { + return [ + ['config.remove', ['captcha_turnstile_sitekey']], + ['config.remove', ['captcha_turnstile_secret']], + ['config.remove', ['captcha_turnstile_theme']], + ]; + } +} diff --git a/phpBB/phpbb/language/language.php b/phpBB/phpbb/language/language.php index dee8b58486..1c51049de5 100644 --- a/phpBB/phpbb/language/language.php +++ b/phpBB/phpbb/language/language.php @@ -403,6 +403,7 @@ class language $this->lang['USER_LANG'] = $lang_values['user_lang'] ?? 'en-gb'; $this->lang['PLURAL_RULE'] = $lang_values['plural_rule'] ?? 1; $this->lang['RECAPTCHA_LANG'] = $lang_values['recaptcha_lang'] ?? 'en-GB'; + $this->lang['TURNSTILE_LANG'] = $lang_values['turnstile_lang'] ?? 'auto'; // default to auto mode } /** diff --git a/phpBB/phpbb/language/language_file_helper.php b/phpBB/phpbb/language/language_file_helper.php index 995c7b4712..83244d674f 100644 --- a/phpBB/phpbb/language/language_file_helper.php +++ b/phpBB/phpbb/language/language_file_helper.php @@ -110,16 +110,17 @@ class language_file_helper } return [ - 'iso' => $data['extra']['language-iso'], - 'name' => $data['extra']['english-name'], - 'local_name' => $data['extra']['local-name'], - 'author' => implode(', ', $authors), - 'version' => $data['version'], - 'phpbb_version' => $data['extra']['phpbb-version'], - 'direction' => $data['extra']['direction'], - 'user_lang' => $data['extra']['user-lang'], - 'plural_rule' => $data['extra']['plural-rule'], - 'recaptcha_lang'=> $data['extra']['recaptcha-lang'], + 'iso' => $data['extra']['language-iso'], + 'name' => $data['extra']['english-name'], + 'local_name' => $data['extra']['local-name'], + 'author' => implode(', ', $authors), + 'version' => $data['version'], + 'phpbb_version' => $data['extra']['phpbb-version'], + 'direction' => $data['extra']['direction'], + 'user_lang' => $data['extra']['user-lang'], + 'plural_rule' => $data['extra']['plural-rule'], + 'recaptcha_lang' => $data['extra']['recaptcha-lang'], + 'turnstile_lang' => $data['extra']['turnstile-lang'] ?? '', ]; } } diff --git a/phpBB/phpbb/report/controller/report.php b/phpBB/phpbb/report/controller/report.php index b8e2b04583..97dbdbd9bb 100644 --- a/phpBB/phpbb/report/controller/report.php +++ b/phpBB/phpbb/report/controller/report.php @@ -13,6 +13,8 @@ namespace phpbb\report\controller; +use phpbb\captcha\plugins\confirm_type; +use phpbb\captcha\plugins\plugin_interface; use phpbb\exception\http_exception; use phpbb\report\report_handler_interface; use Symfony\Component\HttpFoundation\RedirectResponse; @@ -131,7 +133,7 @@ class report if ($this->config['enable_post_confirm'] && !$this->user->data['is_registered']) { $captcha = $this->captcha_factory->get_instance($this->config['captcha_plugin']); - $captcha->init(CONFIRM_REPORT); + $captcha->init(confirm_type::REPORT); } //Has the report been cancelled? @@ -140,7 +142,7 @@ class report return new RedirectResponse($redirect_url, 302); } - // Check CAPTCHA, if the form was submited + // Check CAPTCHA, if the form was submitted if (!empty($submit) && isset($captcha)) { $captcha_template_array = $this->check_captcha($captcha); @@ -298,18 +300,17 @@ class report /** * Check CAPTCHA * - * @param object $captcha A phpBB CAPTCHA object + * @param plugin_interface $captcha A phpBB CAPTCHA object * @return array template variables which ensures that CAPTCHA's work correctly */ - protected function check_captcha($captcha) + protected function check_captcha(plugin_interface $captcha) { $error = array(); $captcha_hidden_fields = ''; - $visual_confirmation_response = $captcha->validate(); - if ($visual_confirmation_response) + if ($captcha->validate() !== true) { - $error[] = $visual_confirmation_response; + $error[] = $captcha->get_error(); } if (count($error) === 0) diff --git a/phpBB/phpbb/template/twig/extension/forms.php b/phpBB/phpbb/template/twig/extension/forms.php index 5a3d9420db..b146aacfb9 100644 --- a/phpBB/phpbb/template/twig/extension/forms.php +++ b/phpBB/phpbb/template/twig/extension/forms.php @@ -183,6 +183,7 @@ class forms extends AbstractExtension 'GROUP_ONLY' => (bool) ($form_data['group_only'] ?? false), 'SIZE' => (int) ($form_data['size'] ?? 0), 'MULTIPLE' => (bool) ($form_data['multiple'] ?? false), + 'ONCHANGE' => (string) ($form_data['onchange'] ?? ''), ]); } catch (\Twig\Error\Error $e) diff --git a/phpBB/posting.php b/phpBB/posting.php index 63ca4fef97..9f8cce0ead 100644 --- a/phpBB/posting.php +++ b/phpBB/posting.php @@ -455,8 +455,10 @@ if (!$is_authed || !empty($error)) if ($config['enable_post_confirm'] && !$user->data['is_registered']) { - $captcha = $phpbb_container->get('captcha.factory')->get_instance($config['captcha_plugin']); - $captcha->init(CONFIRM_POST); + /** @var \phpbb\captcha\factory $captcha_factory */ + $captcha_factory = $phpbb_container->get('captcha.factory'); + $captcha = $captcha_factory->get_instance($config['captcha_plugin']); + $captcha->init(\phpbb\captcha\plugins\confirm_type::POST); } // Is the user able to post within this forum? @@ -1208,15 +1210,9 @@ if ($submit || $preview || $refresh) if ($config['enable_post_confirm'] && !$user->data['is_registered'] && in_array($mode, array('quote', 'post', 'reply'))) { - $captcha_data = array( - 'message' => $request->variable('message', '', true), - 'subject' => $request->variable('subject', '', true), - 'username' => $request->variable('username', '', true), - ); - $vc_response = $captcha->validate($captcha_data); - if ($vc_response) + if ($captcha->validate() !== true) { - $error[] = $vc_response; + $error[] = $captcha->get_error(); } } @@ -1600,7 +1596,7 @@ if ($submit || $preview || $refresh) ); extract($phpbb_dispatcher->trigger_event('core.posting_modify_submit_post_after', compact($vars))); - if ($config['enable_post_confirm'] && !$user->data['is_registered'] && (isset($captcha) && $captcha->is_solved() === true) && ($mode == 'post' || $mode == 'reply' || $mode == 'quote')) + if ($config['enable_post_confirm'] && !$user->data['is_registered'] && $captcha->is_solved() === true && ($mode == 'post' || $mode == 'reply' || $mode == 'quote')) { $captcha->reset(); } diff --git a/phpBB/styles/all/template/macros/forms/select.twig b/phpBB/styles/all/template/macros/forms/select.twig index 827ccb1121..1ada063e57 100644 --- a/phpBB/styles/all/template/macros/forms/select.twig +++ b/phpBB/styles/all/template/macros/forms/select.twig @@ -8,6 +8,7 @@ name="{{ NAME }}" {% if TOGGLEABLE %}data-togglable-settings="true"{% endif %} {% if MULTIPLE %}multiple="multiple"{% endif %} + {% if ONCHANGE %}onchange="{{ ONCHANGE }}"{% endif %} {% if SIZE %}size="{{ SIZE }}"{% endif %}> {% for element in OPTIONS %} {% if not GROUP_ONLY and element.options %} diff --git a/phpBB/styles/prosilver/template/captcha_turnstile.html b/phpBB/styles/prosilver/template/captcha_turnstile.html new file mode 100644 index 0000000000..1cf4f4c792 --- /dev/null +++ b/phpBB/styles/prosilver/template/captcha_turnstile.html @@ -0,0 +1,23 @@ +{% if CONFIRM_TYPE_REGISTRATION %} +
+
+

{{ lang('CONFIRMATION') }}

+
+{% endif %} +{% if S_TURNSTILE_AVAILABLE %} + + + + + {# The cf-turnstile class is used in JavaScript #} +
+{% else %} + {{ lang('CAPTCHA_TURNSTILE_NOT_AVAILABLE') }} +{% endif %} +{% if CONFIRM_TYPE_REGISTRATION %} +
+
+
+{% endif %} diff --git a/tests/captcha/incomplete_test.php b/tests/captcha/incomplete_test.php index c1da2b6c48..4c5e7c6b27 100644 --- a/tests/captcha/incomplete_test.php +++ b/tests/captcha/incomplete_test.php @@ -11,11 +11,15 @@ * */ +use phpbb\captcha\plugins\confirm_type; use phpbb\captcha\plugins\incomplete; use phpbb\config\config; +use phpbb\language\language; +use phpbb\request\request; use phpbb\template\template; +use phpbb\user; -class phpbb_captcha_incomplete_test extends phpbb_test_case +class phpbb_captcha_incomplete_test extends phpbb_database_test_case { protected config $config; @@ -32,21 +36,34 @@ class phpbb_captcha_incomplete_test extends phpbb_test_case $this->assigned_vars = array_merge($this->assigned_vars, $vars); } + public function getDataSet() + { + return $this->createXMLDataSet(__DIR__ . '/../fixtures/empty.xml'); + } + protected function setUp(): void { global $phpbb_root_path, $phpEx; $this->config = new config([]); $this->template = $this->getMockBuilder('\phpbb\template\twig\twig') - ->setMethods(['assign_vars']) + ->onlyMethods(['assign_vars']) ->disableOriginalConstructor() ->getMock(); $this->template->method('assign_vars') ->willReturnCallback([$this, 'assign_vars']); + $db = $this->new_dbal(); + $language = $this->createMock(language::class); + $request = $this->createMock(request::class); + $user = $this->createMock(user::class); $this->incomplete_captcha = new incomplete( $this->config, + $db, + $language, + $request, $this->template, + $user, $phpbb_root_path, $phpEx ); @@ -57,29 +74,25 @@ class phpbb_captcha_incomplete_test extends phpbb_test_case $this->assertTrue($this->incomplete_captcha->is_available()); $this->assertFalse($this->incomplete_captcha->is_solved()); $this->assertFalse($this->incomplete_captcha->validate()); - $this->assertSame('CAPTCHA_INCOMPLETE', incomplete::get_name()); - $this->incomplete_captcha->init(0); - $this->incomplete_captcha->execute(); - $this->incomplete_captcha->execute_demo(); + $this->assertFalse($this->incomplete_captcha->has_config()); + $this->incomplete_captcha->set_name('foo'); + $this->assertSame('CAPTCHA_INCOMPLETE', $this->incomplete_captcha->get_name()); + $this->incomplete_captcha->init(confirm_type::UNDEFINED); $this->assertEmpty($this->assigned_vars); $this->assertEmpty($this->incomplete_captcha->get_demo_template(0)); - } - - public function test_get_generator_class(): void - { - $this->expectException(\phpbb\exception\runtime_exception::class); - $this->incomplete_captcha->get_generator_class(); + $this->assertEmpty($this->incomplete_captcha->get_error()); + $this->assertSame(0, $this->incomplete_captcha->get_attempt_count()); } public function test_get_tempate(): void { - $this->incomplete_captcha->init(CONFIRM_REG); + $this->incomplete_captcha->init(confirm_type::REGISTRATION); $this->assertSame('captcha_incomplete.html', $this->incomplete_captcha->get_template()); $this->assertEquals('CONFIRM_INCOMPLETE', $this->assigned_vars['CONFIRM_LANG']); $this->assigned_vars = []; - $this->incomplete_captcha->init(CONFIRM_POST); + $this->incomplete_captcha->init(confirm_type::POST); $this->assertSame('captcha_incomplete.html', $this->incomplete_captcha->get_template()); $this->assertEquals('CONFIRM_INCOMPLETE', $this->assigned_vars['CONFIRM_LANG']); } diff --git a/tests/captcha/legacy_wrapper_test.php b/tests/captcha/legacy_wrapper_test.php new file mode 100644 index 0000000000..873012bd6a --- /dev/null +++ b/tests/captcha/legacy_wrapper_test.php @@ -0,0 +1,261 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +use phpbb\captcha\plugins\confirm_type; +use phpbb\captcha\plugins\legacy_wrapper; + +class phpbb_captcha_legacy_wrapper_test extends phpbb_test_case +{ + private $legacy_captcha; + private $legacy_wrapper; + + public function setUp(): void + { + $this->legacy_captcha = $this->createMock(stdClass::class); + $this->legacy_wrapper = new legacy_wrapper($this->legacy_captcha); + } + + public function test_is_available_with_method_exists(): void + { + // Simulate is_available method exists in the legacy captcha + $this->legacy_captcha = $this->getMockBuilder(stdClass::class) + ->addMethods(['is_available']) + ->getMock(); + $this->legacy_captcha->method('is_available')->willReturn(true); + $this->legacy_wrapper = new legacy_wrapper($this->legacy_captcha); + + $this->assertTrue($this->legacy_wrapper->is_available()); + } + + public function test_is_available_without_method_exists(): void + { + // Simulate is_available method does not exist in the legacy captcha + $this->assertFalse($this->legacy_wrapper->is_available()); + } + + public function test_has_config_with_method_exists(): void + { + // Simulate has_config method exists in the legacy captcha + $this->legacy_captcha = $this->getMockBuilder(stdClass::class) + ->addMethods(['has_config']) + ->getMock(); + $this->legacy_wrapper = new legacy_wrapper($this->legacy_captcha); + $this->legacy_captcha->method('has_config')->willReturn(true); + + $this->assertTrue($this->legacy_wrapper->has_config()); + } + + public function test_has_config_without_method_exists(): void + { + // Simulate has_config method does not exist in the legacy captcha + $this->assertFalse($this->legacy_wrapper->has_config()); + } + + public function test_get_name_with_method_exists(): void + { + // Simulate get_name method exists in the legacy captcha + $this->legacy_captcha = $this->getMockBuilder(stdClass::class) + ->addMethods(['get_name']) + ->getMock(); + $this->legacy_wrapper = new legacy_wrapper($this->legacy_captcha); + $this->legacy_captcha->method('get_name')->willReturn('LegacyCaptchaName'); + + $this->assertSame('LegacyCaptchaName', $this->legacy_wrapper->get_name()); + } + + public function test_get_name_without_method_exists(): void + { + // Simulate get_name method does not exist in the legacy captcha + $this->assertSame('', $this->legacy_wrapper->get_name()); + } + + public function test_set_name_with_method_exists(): void + { + // Simulate set_name method exists in the legacy captcha + $this->legacy_captcha = $this->getMockBuilder(stdClass::class) + ->addMethods(['set_name']) + ->getMock(); + $this->legacy_wrapper = new legacy_wrapper($this->legacy_captcha); + $this->legacy_captcha->expects($this->once())->method('set_name')->with('NewName'); + + $this->legacy_wrapper->set_name('NewName'); + } + + public function test_init_with_method_exists(): void + { + // Simulate init method exists in the legacy captcha + $this->legacy_captcha = $this->getMockBuilder(stdClass::class) + ->addMethods(['init']) + ->getMock(); + $this->legacy_wrapper = new legacy_wrapper($this->legacy_captcha); + $this->legacy_captcha->expects($this->once())->method('init')->with(confirm_type::REGISTRATION->value); + + $this->legacy_wrapper->init(confirm_type::REGISTRATION); + } + + public function test_get_hidden_fields_with_method_exists(): void + { + // Simulate get_hidden_fields method exists in the legacy captcha + $this->legacy_captcha = $this->getMockBuilder(stdClass::class) + ->addMethods(['get_hidden_fields']) + ->getMock(); + $this->legacy_wrapper = new legacy_wrapper($this->legacy_captcha); + $this->legacy_captcha->method('get_hidden_fields')->willReturn(['field1' => 'value1']); + + $this->assertSame(['field1' => 'value1'], $this->legacy_wrapper->get_hidden_fields()); + } + + public function test_get_hidden_fields_without_method_exists(): void + { + // Simulate get_hidden_fields method does not exist in the legacy captcha + $this->assertSame([], $this->legacy_wrapper->get_hidden_fields()); + } + + public function test_validate_with_error(): void + { + // Simulate validate method returns an error + $this->legacy_captcha = $this->getMockBuilder(stdClass::class) + ->addMethods(['validate']) + ->getMock(); + $this->legacy_wrapper = new legacy_wrapper($this->legacy_captcha); + $this->legacy_captcha->method('validate')->willReturn('Captcha Error'); + + $this->assertFalse($this->legacy_wrapper->validate()); + $this->assertSame('Captcha Error', $this->legacy_wrapper->get_error()); + } + + public function test_validate_without_method_exists(): void + { + $this->assertFalse($this->legacy_wrapper->validate()); + } + + public function test_validate_without_error(): void + { + // Simulate validate method does not return an error + $this->legacy_captcha = $this->getMockBuilder(stdClass::class) + ->addMethods(['validate']) + ->getMock(); + $this->legacy_wrapper = new legacy_wrapper($this->legacy_captcha); + $this->legacy_captcha->method('validate')->willReturn(null); + + $this->assertTrue($this->legacy_wrapper->validate()); + } + + public function test_is_solved_with_method_exists(): void + { + // Simulate is_solved method exists in the legacy captcha + $this->legacy_captcha = $this->getMockBuilder(stdClass::class) + ->addMethods(['is_solved']) + ->getMock(); + $this->legacy_wrapper = new legacy_wrapper($this->legacy_captcha); + $this->legacy_captcha->method('is_solved')->willReturn(true); + + $this->assertTrue($this->legacy_wrapper->is_solved()); + } + + public function test_is_solved_without_method_exists(): void + { + // Simulate is_solved method does not exist in the legacy captcha + $this->assertFalse($this->legacy_wrapper->is_solved()); + } + + public function test_reset_with_method_exists(): void + { + // Simulate reset method exists in the legacy captcha + $this->legacy_captcha = $this->getMockBuilder(stdClass::class) + ->addMethods(['reset']) + ->getMock(); + $this->legacy_wrapper = new legacy_wrapper($this->legacy_captcha); + $this->legacy_captcha->expects($this->once())->method('reset'); + + $this->legacy_wrapper->reset(); + } + + public function test_get_attempt_count_with_method_exists(): void + { + // Simulate get_attempt_count method exists in the legacy captcha + $this->legacy_captcha = $this->getMockBuilder(stdClass::class) + ->addMethods(['get_attempt_count']) + ->getMock(); + $this->legacy_wrapper = new legacy_wrapper($this->legacy_captcha); + $this->legacy_captcha->method('get_attempt_count')->willReturn(5); + + $this->assertSame(5, $this->legacy_wrapper->get_attempt_count()); + } + + public function test_get_attempt_count_without_method_exists(): void + { + // Simulate get_attempt_count method does not exist in the legacy captcha + $this->assertSame(PHP_INT_MAX, $this->legacy_wrapper->get_attempt_count()); + } + + public function test_get_template_with_method_exists(): void + { + // Simulate get_template method exists in the legacy captcha + $this->legacy_captcha = $this->getMockBuilder(stdClass::class) + ->addMethods(['get_template']) + ->getMock(); + $this->legacy_wrapper = new legacy_wrapper($this->legacy_captcha); + $this->legacy_captcha->method('get_template')->willReturn('template_content'); + + $this->assertSame('template_content', $this->legacy_wrapper->get_template()); + } + + public function test_get_template_without_method_exists(): void + { + // Simulate get_template method does not exist in the legacy captcha + $this->assertSame('', $this->legacy_wrapper->get_template()); + } + + public function test_get_demo_template_with_method_exists(): void + { + // Simulate get_demo_template method exists in the legacy captcha + $this->legacy_captcha = $this->getMockBuilder(stdClass::class) + ->addMethods(['get_demo_template']) + ->getMock(); + $this->legacy_wrapper = new legacy_wrapper($this->legacy_captcha); + $this->legacy_captcha->method('get_demo_template')->willReturn('demo_template_content'); + + $this->assertSame('demo_template_content', $this->legacy_wrapper->get_demo_template()); + } + + public function test_get_demo_template_without_method_exists(): void + { + // Simulate get_demo_template method does not exist in the legacy captcha + $this->assertSame('', $this->legacy_wrapper->get_demo_template()); + } + + public function test_garbage_collect_with_method_exists(): void + { + // Simulate garbage_collect method exists in the legacy captcha + $this->legacy_captcha = $this->getMockBuilder(stdClass::class) + ->addMethods(['garbage_collect']) + ->getMock(); + $this->legacy_wrapper = new legacy_wrapper($this->legacy_captcha); + $this->legacy_captcha->expects($this->once())->method('garbage_collect')->with(confirm_type::REGISTRATION->value); + + $this->legacy_wrapper->garbage_collect(confirm_type::REGISTRATION); + } + + public function test_acp_page_with_method_exists(): void + { + // Simulate acp_page method exists in the legacy captcha + $this->legacy_captcha = $this->getMockBuilder(stdClass::class) + ->addMethods(['acp_page']) + ->getMock(); + $this->legacy_wrapper = new legacy_wrapper($this->legacy_captcha); + $this->legacy_captcha->expects($this->once())->method('acp_page')->with(1, 'module'); + + $this->legacy_wrapper->acp_page(1, 'module'); + } +} diff --git a/tests/captcha/qa_test.php b/tests/captcha/qa_test.php index d429336104..1c61a7d10c 100644 --- a/tests/captcha/qa_test.php +++ b/tests/captcha/qa_test.php @@ -41,10 +41,6 @@ class phpbb_captcha_qa_test extends \phpbb_database_test_case public function test_is_installed() { - $this->assertFalse($this->qa->is_installed()); - - $this->qa->install(); - $this->assertTrue($this->qa->is_installed()); } diff --git a/tests/captcha/turnstile_test.php b/tests/captcha/turnstile_test.php new file mode 100644 index 0000000000..f78c4d3177 --- /dev/null +++ b/tests/captcha/turnstile_test.php @@ -0,0 +1,547 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +use phpbb\captcha\plugins\confirm_type; +use phpbb\captcha\plugins\turnstile; +use phpbb\config\config; +use phpbb\db\driver\driver_interface; +use phpbb\form\form_helper; +use phpbb\language\language; +use phpbb\log\log_interface; +use phpbb\request\request; +use phpbb\request\request_interface; +use phpbb\template\template; +use phpbb\user; +use GuzzleHttp\Client; +use GuzzleHttp\Psr7\Response; + +require_once __DIR__ . '/../../phpBB/includes/functions_acp.php'; + +class phpbb_captcha_turnstile_test extends \phpbb_database_test_case +{ + /** @var turnstile */ + protected $turnstile; + + /** @var PHPUnit\Framework\MockObject\MockObject */ + protected $config; + + /** @var PHPUnit\Framework\MockObject\MockObject */ + protected $db; + + /** @var PHPUnit\Framework\MockObject\MockObject */ + protected $language; + + /** @var PHPUnit\Framework\MockObject\MockObject */ + protected $log; + + /** @var PHPUnit\Framework\MockObject\MockObject */ + protected $request; + + /** @var PHPUnit\Framework\MockObject\MockObject */ + protected $template; + + /** @var PHPUnit\Framework\MockObject\MockObject */ + protected $user; + + public function getDataSet() + { + return $this->createXMLDataSet(__DIR__ . '/../fixtures/empty.xml'); + } + + protected function setUp(): void + { + // Mock the dependencies + $this->config = $this->createMock(config::class); + $this->db = $this->new_dbal(); + $this->language = $this->createMock(language::class); + $this->log = $this->createMock(log_interface::class); + $this->request = $this->createMock(request::class); + $this->template = $this->createMock(template::class); + $this->user = $this->createMock(user::class); + + $this->language->method('lang')->willReturnArgument(0); + + // Instantiate the turnstile class with the mocked dependencies + $this->turnstile = new turnstile( + $this->config, + $this->db, + $this->language, + $this->log, + $this->request, + $this->template, + $this->user + ); + } + + public function test_is_available(): void + { + // Test when both sitekey and secret are present + $this->config->method('offsetGet')->willReturnMap([ + ['captcha_turnstile_sitekey', 'sitekey_value'], + ['captcha_turnstile_secret', 'secret_value'], + ]); + + $this->request->method('variable')->willReturnMap([ + ['confirm_id', '', false, request_interface::REQUEST, 'confirm_id'], + ['confirm_code', '', false, request_interface::REQUEST, 'confirm_code'] + ]); + + $this->assertTrue($this->turnstile->is_available()); + + $this->assertEquals(0, $this->turnstile->get_attempt_count()); + } + + public function test_attempt_count_increase(): void + { + // Test when both sitekey and secret are present + $this->config->method('offsetGet')->willReturnMap([ + ['captcha_turnstile_sitekey', 'sitekey_value'], + ['captcha_turnstile_secret', 'secret_value'], + ]); + + $this->request->method('variable')->willReturnMap([ + ['confirm_id', '', false, request_interface::REQUEST, 'confirm_id'], + ['confirm_code', '', false, request_interface::REQUEST, 'confirm_code'] + ]); + + $this->turnstile->init(confirm_type::REGISTRATION); + $this->assertFalse($this->turnstile->validate()); + + $confirm_id_reflection = new \ReflectionProperty($this->turnstile, 'confirm_id'); + $confirm_id = $confirm_id_reflection->getValue($this->turnstile); + + $this->request = $this->createMock(request::class); + $this->request->method('variable')->willReturnMap([ + ['confirm_id', '', false, request_interface::REQUEST, $confirm_id], + ['confirm_code', '', false, request_interface::REQUEST, 'confirm_code'] + ]); + + $this->turnstile = new turnstile( + $this->config, + $this->db, + $this->language, + $this->log, + $this->request, + $this->template, + $this->user + ); + + $this->turnstile->init(confirm_type::REGISTRATION); + $this->assertEquals(1, $this->turnstile->get_attempt_count()); + + // Run some garbage collection + $this->turnstile->garbage_collect(confirm_type::REGISTRATION); + + // Start again at 0 after garbage collection + $this->turnstile->init(confirm_type::REGISTRATION); + $this->assertEquals(0, $this->turnstile->get_attempt_count()); + } + + public function test_reset(): void + { + // Test when both sitekey and secret are present + $this->config->method('offsetGet')->willReturnMap([ + ['captcha_turnstile_sitekey', 'sitekey_value'], + ['captcha_turnstile_secret', 'secret_value'], + ]); + + $this->request->method('variable')->willReturnMap([ + ['confirm_id', '', false, request_interface::REQUEST, 'confirm_id'], + ['confirm_code', '', false, request_interface::REQUEST, 'confirm_code'] + ]); + + $this->turnstile->init(confirm_type::REGISTRATION); + $this->assertFalse($this->turnstile->validate()); + $this->turnstile->reset(); + + $confirm_id_reflection = new \ReflectionProperty($this->turnstile, 'confirm_id'); + $confirm_id = $confirm_id_reflection->getValue($this->turnstile); + + $this->request = $this->createMock(request::class); + $this->request->method('variable')->willReturnMap([ + ['confirm_id', '', false, request_interface::REQUEST, $confirm_id], + ['confirm_code', '', false, request_interface::REQUEST, 'confirm_code'] + ]); + + $this->turnstile = new turnstile( + $this->config, + $this->db, + $this->language, + $this->log, + $this->request, + $this->template, + $this->user + ); + + $this->turnstile->init(confirm_type::REGISTRATION); + // Should be zero attempts since we reset the captcha + $this->assertEquals(0, $this->turnstile->get_attempt_count()); + } + + public function test_get_hidden_fields(): void + { + // Test when both sitekey and secret are present + $this->config->method('offsetGet')->willReturnMap([ + ['captcha_turnstile_sitekey', 'sitekey_value'], + ['captcha_turnstile_secret', 'secret_value'], + ]); + + $this->request->method('variable')->willReturnMap([ + ['confirm_id', '', false, request_interface::REQUEST, 'confirm_id'], + ['confirm_code', '', false, request_interface::REQUEST, 'confirm_code'] + ]); + + $this->turnstile->init(confirm_type::REGISTRATION); + $this->assertFalse($this->turnstile->validate()); + $this->turnstile->reset(); + + $confirm_id_reflection = new \ReflectionProperty($this->turnstile, 'confirm_id'); + $confirm_id = $confirm_id_reflection->getValue($this->turnstile); + + $this->assertEquals( + [ + 'confirm_id' => $confirm_id, + 'confirm_code' => '', + ], + $this->turnstile->get_hidden_fields(), + ); + $this->assertEquals('CONFIRM_CODE_WRONG', $this->turnstile->get_error()); + } + + public function test_not_available(): void + { + $this->request->method('variable')->willReturnMap([ + ['confirm_id', '', false, request_interface::REQUEST, 'confirm_id'], + ['confirm_code', '', false, request_interface::REQUEST, 'confirm_code'] + ]); + + // Test when sitekey or secret is missing + $this->config->method('offsetGet')->willReturnMap([ + ['captcha_turnstile_sitekey', ''], + ['captcha_turnstile_secret', 'secret_value'], + ]); + + $this->assertFalse($this->turnstile->is_available()); + } + + public function test_get_name(): void + { + $this->assertEquals('CAPTCHA_TURNSTILE', $this->turnstile->get_name()); + } + + public function test_set_Name(): void + { + $this->turnstile->set_name('custom_service'); + $service_name_property = new \ReflectionProperty($this->turnstile, 'service_name'); + $service_name_property->setAccessible(true); + $this->assertEquals('custom_service', $service_name_property->getValue($this->turnstile)); + } + + public function test_validate_without_response(): void + { + // Test when there is no Turnstile response + $this->request->method('variable')->with('cf-turnstile-response')->willReturn(''); + + $this->assertFalse($this->turnstile->validate()); + } + + public function test_validate_with_response_success(): void + { + // Mock the request and response from the Turnstile API + $this->request->method('variable')->with('cf-turnstile-response')->willReturn('valid_response'); + $this->request->method('header')->with('CF-Connecting-IP')->willReturn('127.0.0.1'); + + // Mock the GuzzleHttp client and response + $client_mock = $this->createMock(Client::class); + $response_mock = $this->createMock(Response::class); + + $client_mock->method('request')->willReturn($response_mock); + $response_mock->method('getBody')->willReturn(json_encode(['success' => true])); + + // Mock config values for secret + $this->config->method('offsetGet')->willReturn('secret_value'); + + // Use reflection to inject the mocked client into the turnstile class + $reflection = new \ReflectionClass($this->turnstile); + $client_property = $reflection->getProperty('client'); + $client_property->setAccessible(true); + $client_property->setValue($this->turnstile, $client_mock); + + // Validate that the CAPTCHA was solved successfully + $this->assertTrue($this->turnstile->validate()); + } + + public function test_validate_with_guzzle_exception(): void + { + // Mock the request and response from the Turnstile API + $this->request->method('variable')->with('cf-turnstile-response')->willReturn('valid_response'); + $this->request->method('header')->with('CF-Connecting-IP')->willReturn('127.0.0.1'); + + // Mock the GuzzleHttp client and response + $client_mock = $this->createMock(Client::class); + + $request_mock = $this->createMock(\GuzzleHttp\Psr7\Request::class); + $exception = new \GuzzleHttp\Exception\ConnectException('Failed at connecting', $request_mock); + $client_mock->method('request')->willThrowException($exception); + + // Mock config values for secret + $this->config->method('offsetGet')->willReturn('secret_value'); + + // Use reflection to inject the mocked client into the turnstile class + $reflection = new \ReflectionClass($this->turnstile); + $client_property = $reflection->getProperty('client'); + $client_property->setAccessible(true); + $client_property->setValue($this->turnstile, $client_mock); + + // Validatation fails due to guzzle exception + $this->assertFalse($this->turnstile->validate()); + } + + public function test_validate_previous_solve(): void + { + // Use reflection to inject the mocked client into the turnstile class + $reflection = new \ReflectionClass($this->turnstile); + $confirm_id = $reflection->getProperty('confirm_id'); + $confirm_id->setValue($this->turnstile, 'confirm_id'); + $code_property = $reflection->getProperty('code'); + $code_property->setValue($this->turnstile, 'test_code'); + $confirm_code_property = $reflection->getProperty('confirm_code'); + $confirm_code_property->setValue($this->turnstile, 'test_code'); + + // Validate that the CAPTCHA was solved successfully + $this->assertTrue($this->turnstile->validate()); + $this->assertTrue($this->turnstile->is_solved()); + } + + public function test_has_config(): void + { + $this->assertTrue($this->turnstile->has_config()); + } + + public function test_get_client(): void + { + $turnstile_reflection = new \ReflectionClass($this->turnstile); + $get_client_method = $turnstile_reflection->getMethod('get_client'); + $get_client_method->setAccessible(true); + $client_property = $turnstile_reflection->getProperty('client'); + $client_property->setAccessible(true); + + $this->assertFalse($client_property->isInitialized($this->turnstile)); + $client = $get_client_method->invoke($this->turnstile); + $this->assertNotNull($client); + $this->assertInstanceOf(\GuzzleHttp\Client::class, $client); + $this->assertTrue($client === $get_client_method->invoke($this->turnstile)); + } + + public function test_validate_with_response_failure(): void + { + // Mock the request and response from the Turnstile API + $this->request->method('variable')->with('cf-turnstile-response')->willReturn('valid_response'); + $this->request->method('header')->with('CF-Connecting-IP')->willReturn('127.0.0.1'); + + // Mock the GuzzleHttp client and response + $client_mock = $this->createMock(Client::class); + $response_mock = $this->createMock(Response::class); + + $client_mock->method('request')->willReturn($response_mock); + $response_mock->method('getBody')->willReturn(json_encode(['success' => false])); + + // Mock config values for secret + $this->config->method('offsetGet')->willReturn('secret_value'); + + // Use reflection to inject the mocked client into the turnstile class + $reflection = new \ReflectionClass($this->turnstile); + $client_property = $reflection->getProperty('client'); + $client_property->setAccessible(true); + $client_property->setValue($this->turnstile, $client_mock); + + // Validate that the CAPTCHA was not solved + $this->assertFalse($this->turnstile->validate()); + } + + public function test_get_template(): void + { + // Mock is_solved to return false + $is_solved_property = new \ReflectionProperty($this->turnstile, 'solved'); + $is_solved_property->setAccessible(true); + $is_solved_property->setValue($this->turnstile, false); + + // Mock the template assignments + $this->config->method('offsetGet')->willReturnMap([ + ['captcha_turnstile_sitekey', 'sitekey_value'], + ['captcha_turnstile_theme', 'light'], + ]); + + $this->request->method('variable')->willReturnMap([ + ['confirm_id', '', false, request_interface::REQUEST, 'confirm_id'], + ['confirm_code', '', false, request_interface::REQUEST, 'confirm_code'] + ]); + + $this->template->expects($this->once())->method('assign_vars')->with([ + 'S_TURNSTILE_AVAILABLE' => $this->turnstile->is_available(), + 'TURNSTILE_SITEKEY' => 'sitekey_value', + 'TURNSTILE_THEME' => 'light', + 'U_TURNSTILE_SCRIPT' => 'https://challenges.cloudflare.com/turnstile/v0/api.js', + 'CONFIRM_TYPE_REGISTRATION' => confirm_type::UNDEFINED->value, + ]); + + $this->assertEquals('captcha_turnstile.html', $this->turnstile->get_template()); + + $is_solved_property->setValue($this->turnstile, true); + $this->assertEquals('', $this->turnstile->get_template()); + } + + public function test_get_demo_template(): void + { + // Mock the config assignments + $this->config->method('offsetGet')->willReturn('light'); + + $this->template->expects($this->once())->method('assign_vars')->with([ + 'TURNSTILE_THEME' => 'light', + 'U_TURNSTILE_SCRIPT' => 'https://challenges.cloudflare.com/turnstile/v0/api.js', + ]); + + $this->assertEquals('captcha_turnstile_acp_demo.html', $this->turnstile->get_demo_template()); + } + + public function test_acp_page_display(): void + { + global $phpbb_container, $phpbb_dispatcher, $template; + + $phpbb_container = new phpbb_mock_container_builder(); + $form_helper = new form_helper($this->config, $this->request, $this->user); + $phpbb_container->set('form_helper', $form_helper); + $this->user->data['user_id'] = ANONYMOUS; + $this->user->data['user_form_salt'] = 'foobar'; + + $phpbb_dispatcher = new phpbb_mock_event_dispatcher(); + $template = $this->template; + + // Mock the template assignments + $this->config->method('offsetGet')->willReturnMap([ + ['captcha_turnstile_sitekey', 'sitekey_value'], + ['captcha_turnstile_theme', 'light'], + ]); + + $this->request->method('variable')->willReturn(''); + + $expected = [ + 1 => [ + 'TURNSTILE_THEME' => 'light', + 'U_TURNSTILE_SCRIPT' => 'https://challenges.cloudflare.com/turnstile/v0/api.js', + ], + 2 => [ + 'CAPTCHA_PREVIEW' => 'captcha_turnstile_acp_demo.html', + 'CAPTCHA_NAME' => '', + 'CAPTCHA_TURNSTILE_THEME' => 'light', + 'CAPTCHA_TURNSTILE_THEMES' => ['light', 'dark', 'auto'], + 'U_ACTION' => 'test_u_action', + ], + ]; + $matcher = $this->exactly(count($expected)); + $this->template + ->expects($matcher) + ->method('assign_vars') + ->willReturnCallback(function ($template_data) use ($matcher, $expected) { + $callNr = $matcher->getInvocationCount(); + $this->assertEquals($expected[$callNr], $template_data); + }); + + $module_mock = new ModuleMock(); + + $this->turnstile->acp_page('', $module_mock); + } + + public function test_acp_page_submit_without_form(): void + { + global $language, $phpbb_container, $phpbb_dispatcher, $template; + + $language = $this->language; + $phpbb_container = new phpbb_mock_container_builder(); + $form_helper = new form_helper($this->config, $this->request, $this->user); + $phpbb_container->set('form_helper', $form_helper); + $this->user->data['user_id'] = ANONYMOUS; + $this->user->data['user_form_salt'] = 'foobar'; + + $phpbb_dispatcher = new phpbb_mock_event_dispatcher(); + $template = $this->template; + + // Mock the template assignments + $this->config->method('offsetGet')->willReturnMap([ + ['captcha_turnstile_sitekey', 'sitekey_value'], + ['captcha_turnstile_theme', 'light'], + ]); + + $this->request->method('is_set_post')->willReturnMap([ + ['creation_time', ''], + ['submit', true] + ]); + + $this->setExpectedTriggerError(E_USER_NOTICE, 'FORM_INVALID'); + + $module_mock = new ModuleMock(); + + $this->turnstile->acp_page('', $module_mock); + } + + public function test_acp_page_submit_valid(): void + { + global $language, $phpbb_container, $phpbb_dispatcher, $template; + + $language = $this->language; + $phpbb_container = new phpbb_mock_container_builder(); + $form_helper = new form_helper($this->config, $this->request, $this->user); + $phpbb_container->set('form_helper', $form_helper); + $this->user->data['user_id'] = ANONYMOUS; + $this->user->data['user_form_salt'] = 'foobar'; + + $phpbb_dispatcher = new phpbb_mock_event_dispatcher(); + $template = $this->template; + + $form_tokens = $form_helper->get_form_tokens('acp_captcha'); + + // Mock the template assignments + $this->config->method('offsetGet')->willReturnMap([ + ['captcha_turnstile_sitekey', 'sitekey_value'], + ['captcha_turnstile_theme', 'light'], + ]); + $this->config['form_token_lifetime'] = 3600; + + $this->request->method('is_set_post')->willReturnMap([ + ['creation_time', true], + ['form_token', true], + ['submit', true] + ]); + + $this->request->method('variable')->willReturnMap([ + ['creation_time', 0, false, request_interface::REQUEST, $form_tokens['creation_time']], + ['form_token', '', false, request_interface::REQUEST, $form_tokens['form_token']], + ['captcha_turnstile_sitekey', '', false, request_interface::REQUEST, 'newsitekey'], + ['captcha_turnstile_theme', 'light', false, request_interface::REQUEST, 'auto'], + ]); + + $this->setExpectedTriggerError(E_USER_NOTICE, 'CONFIG_UPDATED'); + + $module_mock = new ModuleMock(); + sleep(1); // sleep for a second to ensure form token validation succeeds + + $this->turnstile->acp_page('', $module_mock); + } +} + +class ModuleMock +{ + public string $tpl_name = ''; + public string $page_title = ''; + public string $u_action = 'test_u_action'; +}