is_gravityforms_supported()) { return; } require_once 'class-math-captcha-field.php'; } /** * Initialize required hooks for admin and theme. * * @since 1.0 * * @return void */ public function init() { parent::init(); add_action('wp_ajax_alphagf_refresh_math_captcha', array($this, 'refresh_math_captcha')); add_action('wp_ajax_nopriv_alphagf_refresh_math_captcha', array($this, 'refresh_math_captcha')); add_action('gform_after_submission', array($this, 'mark_tokens_used'), 10, 2); } /** * Initialize hooks required for admin. * * @since 1.0 * * @return void */ public function init_admin() { parent::init_admin(); } public function styles() { $styles = array( array( 'handle' => 'alphagf_math_style', 'src' => ALPHAGF_ASSETS . '/css/alphagf_addons_style.css', 'version' => $this->_version, 'enqueue' => array( array( 'field_type' => array('alphagf_math_captcha'), ) ) ) ); return array_merge(parent::styles(), $styles); } /** * Return the scripts which should be enqueued. * * @return array */ public function scripts() { $scripts = array( array( 'handle' => 'alphagf_math_captcha_script', 'src' => trailingslashit($this->get_base_url()) . 'js/math-captcha.js', 'version' => $this->_version, 'deps' => array('jquery'), 'in_footer' => true, 'enqueue' => array( array( 'field_type' => array('alphagf_math_captcha'), ) ), 'callback' => array($this, 'localize_scripts'), ), array( 'handle' => 'alphagf_math_captcha_admin_script', 'src' => trailingslashit($this->get_base_url()) . 'js/math-captcha-admin.js', 'version' => $this->_version, 'deps' => array('jquery'), 'in_footer' => true, 'enqueue' => array( array('admin_page' => array('form_editor')), ), ) ); return array_merge(parent::scripts(), $scripts); } public function localize_scripts() { wp_localize_script( 'alphagf_math_captcha_script', 'alphagfMathCaptcha', array( 'ajaxUrl' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('alphagf_math_captcha_nonce'), ) ); } public function form_has_errors($form) { foreach ($form['fields'] as $field) { if ($field->failed_validation) { return true; } } return false; } public function refresh_math_captcha() { check_ajax_referer('alphagf_math_captcha_nonce', 'nonce'); $difficulty = 'easy'; $operations = array('addition', 'subtraction'); $question = $this->generate_math_problem($difficulty, $operations); wp_send_json_success([ 'hash' => $question['token'], 'image' => $this->generate_svg_captcha($question['question']) ]); wp_die(); } public function generate_svg_captcha($text) { $width = 140; $height = 40; $padding = 14; $chars = mb_str_split($text); $count = count($chars); $gap = ($width - ($padding * 2)) / max($count, 1); $colors = array( '#e74c3c', '#3498db', '#2ecc71', '#9b59b6', '#f39c12' ); $svg = ''; $svg .= ' '; $svg .= ''; for ($i = 0; $i < 3; $i++) { $svg .= ''; } $y = $height / 2; foreach ($chars as $i => $char) { $x = $padding + ($gap * $i) + ($gap / 2); $svg .= sprintf( ' %s ', $x, $y + wp_rand(-2, 2), $colors[array_rand($colors)], wp_rand(-12, 12), $x, $y, esc_html($char) ); } $svg .= ''; return 'data:image/svg+xml;base64,' . base64_encode($svg); } public function generate_math_problem($difficulty, $operations) { switch ($difficulty) { case 'hard': $min = 50; $max = 999; break; case 'medium': $min = 10; $max = 99; break; case 'easy': default: $min = 1; $max = 20; break; } $num1 = wp_rand($min, $max); $num2 = wp_rand($min, $max); $operation = $operations[array_rand($operations)]; switch ($operation) { case 'subtraction': if ($num1 < $num2) { $temp = $num1; $num1 = $num2; $num2 = $temp; } $answer = $num1 - $num2; $operator = '-'; break; case 'multiplication': $num1 = wp_rand(1, 12); $num2 = wp_rand(1, 12); $answer = $num1 * $num2; $operator = '×'; break; case 'addition': default: $answer = $num1 + $num2; $operator = '+'; break; } return array( 'num1' => $num1, 'num2' => $num2, 'operator' => $operator, 'operation' => $operation, 'question' => sprintf('%d %s %d = ?', $num1, $operator, $num2), 'token' => $this->create_token(sprintf('%d %s %d = ?', $num1, $operator, $num2), $answer), ); } /** * Build a signed, expiring token for a captcha question. * * The answer itself is never sent to the browser: the token carries the question, an expiry and a random * nonce, signed together with the answer. A previously solved token can't be reused once the form has been * submitted (see mark_tokens_used()), and it stops working when it expires. * * @param string $question The question shown in the image. * @param int $answer The expected answer. * * @return string */ public function create_token($question, $answer) { $payload = base64_encode(wp_json_encode(array( 'q' => $question, 'e' => time() + self::TOKEN_LIFETIME, 'n' => wp_generate_password(12, false, false), ))); return $payload . '.' . $this->sign_token($payload, $answer); } private function sign_token($payload, $answer) { return hash_hmac('sha256', $payload . '|' . (int) $answer, wp_salt('nonce')); } /** * Decode a token's payload without checking the answer. * * @return array|false Array with 'q' (question) and 'e' (expiry), or false when malformed or expired. */ public function parse_token($token) { $parts = explode('.', (string) $token); if (count($parts) !== 2) { return false; } $data = json_decode((string) base64_decode($parts[0], true), true); if (! is_array($data) || empty($data['q']) || empty($data['e']) || (int) $data['e'] < time()) { return false; } return $data; } /** * Check a submitted answer against a token. */ public function verify_answer($token, $answer) { if (! is_scalar($answer)) { return false; } $answer = trim((string) $answer); // Must be a whole number; an empty value must never pass (some questions have 0 as the answer). if (! preg_match('/^-?\d{1,6}$/', $answer)) { return false; } if (! $this->parse_token($token) || $this->is_token_used($token)) { return false; } list($payload, $signature) = explode('.', (string) $token); return hash_equals($this->sign_token($payload, (int) $answer), $signature); } private function get_used_token_key($token) { return 'alphagf_mc_used_' . md5((string) $token); } public function is_token_used($token) { return (bool) get_transient($this->get_used_token_key($token)); } /** * After a successful submission, burn the tokens it used so the same solved captcha can't be replayed. */ public function mark_tokens_used($entry, $form) { foreach ((array) rgar($form, 'fields') as $field) { if ('alphagf_math_captcha' !== rgobj($field, 'type')) { continue; } $token = (string) rgpost('input_' . $field->id . '_hash'); $data = $this->parse_token($token); if ($data) { set_transient($this->get_used_token_key($token), 1, max(60, (int) $data['e'] - time())); } } } }