initial
This commit is contained in:
+412
@@ -0,0 +1,412 @@
|
||||
<?php
|
||||
|
||||
namespace Gravity_Forms\Gravity_Forms_RECAPTCHA;
|
||||
|
||||
use GFCache;
|
||||
use GFCommon;
|
||||
|
||||
/**
|
||||
* Class GF_Field_RECAPTCHA_Checkbox
|
||||
*
|
||||
* @since 2.2.0
|
||||
*
|
||||
* @package Gravity_Forms\Gravity_Forms_RECAPTCHA
|
||||
*/
|
||||
class GF_Field_RECAPTCHA_Checkbox extends \GF_Field {
|
||||
|
||||
/**
|
||||
* Recaptcha field type.
|
||||
*
|
||||
* @since 2.2.0
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $type = 'recaptcha_checkbox';
|
||||
|
||||
/**
|
||||
* Whether there can be more than one of this field type per form with GF 3.0+.
|
||||
*
|
||||
* @since 2.2.0
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $duplicatable = false;
|
||||
|
||||
/**
|
||||
* Whether the field can be used in a repeater with GF 3.0+.
|
||||
*
|
||||
* @since 2.2.0
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $repeatable = false;
|
||||
|
||||
/**
|
||||
* Whether the field is for front-end display only use.
|
||||
*
|
||||
* @since 2.2.0
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $displayOnly = true;
|
||||
|
||||
/**
|
||||
* Instantiates the field and adds the necessary hooks.
|
||||
*
|
||||
* @since 2.2.0
|
||||
*
|
||||
* @param array $data The field properties and their values.
|
||||
*/
|
||||
public function __construct( $data = array() ) {
|
||||
parent::__construct( $data );
|
||||
if ( $this->id && $this->formId ) {
|
||||
add_action( 'gform_entry_created', array( $this, 'action_entry_created' ) );
|
||||
add_filter( 'gform_ajax_submission_result' , array( $this, 'filter_ajax_submission_result' ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get field button title.
|
||||
*
|
||||
* @since 2.2.0
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_form_editor_field_title() {
|
||||
return esc_attr__( 'reCAPTCHA Checkbox', 'gravityformsrecaptcha' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the field's form editor icon.
|
||||
*
|
||||
* @since 2.2.0
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_form_editor_field_icon() {
|
||||
return 'gform-icon--recaptcha';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return empty array to prevent the field from showing up in the form editor.
|
||||
*
|
||||
* @since 2.2.0
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_form_editor_button() {
|
||||
if ( ! $this->has_enterprise_checkbox_key() ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
return array(
|
||||
'group' => 'advanced_fields',
|
||||
'text' => $this->get_form_editor_field_title(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the duplicate field link from the admin field buttons.
|
||||
*
|
||||
* @since 2.2.0
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_admin_buttons() {
|
||||
add_filter( 'gform_duplicate_field_link', '__return_empty_string' );
|
||||
$admin_buttons = parent::get_admin_buttons();
|
||||
remove_filter( 'gform_duplicate_field_link', '__return_empty_string' );
|
||||
|
||||
return $admin_buttons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the field's form editor description.
|
||||
*
|
||||
* @since 2.2.0
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_form_editor_field_description() {
|
||||
return esc_attr__( 'Adds a reCAPTCHA Enterprise checkbox field to your form to help protect your website from spam and bot abuse.', 'gravityformsrecaptcha' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get field settings in the form editor.
|
||||
*
|
||||
* @since 2.2.0
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_form_editor_field_settings() {
|
||||
return array(
|
||||
'label_setting',
|
||||
'captcha_theme_setting',
|
||||
'label_placement_setting',
|
||||
'error_message_setting',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the scripts to be included for this field type in the form editor.
|
||||
*
|
||||
* @since 2.2.0
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_form_editor_inline_script_on_page_render() {
|
||||
$type = esc_js( $this->type );
|
||||
|
||||
$cant_add_field_title = json_encode( $this->get_form_editor_field_title() );
|
||||
$cant_add_field_message = json_encode( esc_html__( 'Only one reCAPTCHA checkbox field can be added to the form.', 'gravityformsrecaptcha' ) );
|
||||
|
||||
$default_label = json_encode( __( 'reCAPTCHA', 'gravityformsrecaptcha' ) );
|
||||
|
||||
$a11y_string1 = json_encode( esc_html__( 'This field has accessibility issues. We recommend using a score-based key instead of this field.', 'gravityformsrecaptcha' ) );
|
||||
$a11y_string2 = json_encode( esc_html__( '(opens in a new tab)', 'gravityformsrecaptcha' ) );
|
||||
|
||||
return <<<EOD
|
||||
gform.addFilter( 'gform_form_editor_can_field_be_added', ( canFieldBeAdded, type ) => {
|
||||
if ( type === '{$type}' && GetFieldsByType( [ '{$type}' ] ).length ) {
|
||||
canFieldBeAdded = false;
|
||||
gform.instances.dialogAlert( {$cant_add_field_title}, {$cant_add_field_message} );
|
||||
}
|
||||
|
||||
return canFieldBeAdded;
|
||||
} );
|
||||
window.SetDefaultValues_{$type} = ( field ) => {
|
||||
field.label = {$default_label};
|
||||
field.captchaTheme = 'light';
|
||||
}
|
||||
gform.addAction( 'gform_post_load_field_settings', ( [ field, form ] ) => {
|
||||
if ( field.type !== '{$type}' ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = '<p class="gform-alert__message"><a href="https://docs.gravityforms.com/field-accessibility-warning" target="_blank">' + {$a11y_string1} + '<span class="screen-reader-text">' + {$a11y_string2} + '</span> <span class="gform-icon gform-icon--external-link" aria-hidden="true"></span></a></p>';
|
||||
|
||||
SetFieldAccessibilityWarning( 'label_setting', 'above', message );
|
||||
|
||||
const themeSetting = document.getElementById( 'field_captcha_theme' );
|
||||
if ( themeSetting.value !== field.captchaTheme ) {
|
||||
themeSetting.value = field.captchaTheme;
|
||||
}
|
||||
} );
|
||||
EOD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the warning message to be displayed in the form editor sidebar.
|
||||
*
|
||||
* @since 2.2.0
|
||||
*
|
||||
* @return string|array
|
||||
*/
|
||||
public function get_field_sidebar_messages() {
|
||||
if ( $this->has_enterprise_checkbox_key() ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return array(
|
||||
'type' => 'notice',
|
||||
'content' => sprintf(
|
||||
'%s<div class="gform-spacing gform-spacing--top-1">%s</div>',
|
||||
esc_html__( 'Configuration Required', 'gravityformsrecaptcha' ),
|
||||
// Translators: 1. Opening <a> tag with link to the Forms > Settings > reCAPTCHA page. 2. closing <a> tag.
|
||||
sprintf(
|
||||
esc_html__( 'To use this field, configure the %1$sreCAPTCHA Add-On Settings%2$s using the Enterprise connection type, and select a checkbox type key.', 'gravityformsrecaptcha' ),
|
||||
'<a href="?page=gf_settings&subview=gravityformsrecaptcha" target="_blank">',
|
||||
'<span class="screen-reader-text">' . esc_html__( '(opens in a new tab)', 'gravityformsrecaptcha' ) . '</span> <span class="gform-icon gform-icon--external-link" aria-hidden="true"></span></a>'
|
||||
)
|
||||
),
|
||||
'icon_helper_text' => esc_html__( 'This field requires additional configuration', 'gravityformsrecaptcha' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the enterprise checkbox key is configured.
|
||||
*
|
||||
* @since 2.2.0
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function has_enterprise_checkbox_key() {
|
||||
return gf_recaptcha()->has_enterprise_checkbox_key();
|
||||
}
|
||||
|
||||
/**
|
||||
* The field markup.
|
||||
*
|
||||
* @since 2.2.0
|
||||
*
|
||||
* @param array $form The form array.
|
||||
* @param string $value The field value.
|
||||
* @param array|null $entry The entry array.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_field_input( $form, $value = '', $entry = null ) {
|
||||
if ( $this->is_form_editor() ) {
|
||||
return $this->get_field_input_form_editor();
|
||||
}
|
||||
|
||||
if ( ! $this->has_enterprise_checkbox_key() ) {
|
||||
gf_recaptcha()->log_debug( __METHOD__ . '(): A reCAPTCHA v3 Enterprise checkbox type key is not configured.' );
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
if ( empty( $value ) || ! is_string( $value ) ) {
|
||||
$value = '';
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
"<div class='ginput_container ginput_container_recaptcha_checkbox'><div class='g-recaptcha' data-sitekey='%s' data-theme='%s' data-action='submit' data-tabindex='%d'></div>"
|
||||
. "<input class='gfield_recaptcha_response' type='hidden' name='input_%d' value='%s'/>"
|
||||
. "</div>",
|
||||
esc_attr( gf_recaptcha()->get_plugin_settings_instance()->get_recaptcha_key( 'site_key_v3_enterprise' ) ),
|
||||
GFCommon::whitelist( $this->captchaTheme, array( 'light', 'dark' ) ),
|
||||
esc_attr( GFCommon::$tab_index > 0 ? GFCommon::$tab_index ++ : 0 ),
|
||||
$this->id,
|
||||
esc_attr( $this->get_context_property( 'assessment_id_hash' ) ?: $value )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the field input markup for the form editor.
|
||||
*
|
||||
* @since 2.2.0
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function get_field_input_form_editor() {
|
||||
if ( $this->has_enterprise_checkbox_key() ) {
|
||||
$theme = GFCommon::whitelist( $this->captchaTheme, array( 'light', 'dark' ) );
|
||||
$img_url = GFCommon::get_base_url() . sprintf( '/images/captcha_%s.svg', $theme );
|
||||
|
||||
return sprintf( '<div class="ginput_container"><img class="gfield_captcha" src="%s" alt="%s" width="304" height="78"></div>', esc_attr( $img_url ), esc_attr__( 'An example reCAPTCHA checkbox', 'gravityformsrecaptcha' ) );
|
||||
}
|
||||
|
||||
return '<div class="ginput_container ginput_container_addon_message ginput_container_addon_message_recaptcha_checkbox">
|
||||
<div class="gform-alert gform-alert--info gform-alert--theme-cosmos gform-spacing gform-spacing--bottom-0 gform-theme__disable">
|
||||
<span
|
||||
class="gform-icon gform-icon--information-simple gform-icon--preset-active gform-icon-preset--status-info gform-alert__icon"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<div class="gform-alert__message-wrap">
|
||||
<div class="gform-alert__message">
|
||||
' . esc_html__( 'Configuration Required', 'gravityformsrecaptcha' ) . '
|
||||
<div class="gform-spacing gform-spacing--top-1">' . sprintf(
|
||||
esc_html__( 'To use this field, configure the %1$sreCAPTCHA Add-On Settings%2$s using the Enterprise connection type, and select a checkbox type key.', 'gravityformsrecaptcha' ),
|
||||
'<a href="?page=gf_settings&subview=gravityformsrecaptcha" target="_blank">',
|
||||
'<span class="screen-reader-text">' . esc_html__( '(opens in a new tab)', 'gravityformsrecaptcha' ) . '</span> <span class="gform-icon gform-icon--external-link" aria-hidden="true"></span></a>'
|
||||
) . '</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the submitted reCAPTCHA token or hash of an existing assessment ID.
|
||||
*
|
||||
* @since 2.2.0
|
||||
*
|
||||
* @param string $value The submitted value.
|
||||
* @param array $form The current form.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function validate( $value, $form ) {
|
||||
if ( ( wp_doing_ajax() && rgpost( 'action' ) === 'gfcf_validate_field' ) || ! $this->has_enterprise_checkbox_key() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cache_key_prefix = sprintf( 'gf_recaptcha_assessment_%s_%d_', \GFFormsModel::get_form_unique_id( $this->formId ), $this->id );
|
||||
$token_verifier = gf_recaptcha()->get_token_verifier();
|
||||
|
||||
$token = rgpost( 'g-recaptcha-response' );
|
||||
if ( empty( $token ) ) {
|
||||
$hash = ! empty( $value ) && is_string( $value ) ? sanitize_key( $value ) : null;
|
||||
if ( $hash ) {
|
||||
$assessment = GFCache::get( $cache_key_prefix . $hash, $found );
|
||||
if ( $found && ! empty( $assessment['name'] ) && $hash === wp_hash( $assessment['name'] ) ) {
|
||||
// Setting to the cached assessment so it is available when the entry spam check runs.
|
||||
$token_verifier->set_recaptcha_result( $assessment );
|
||||
$this->set_context_property( 'assessment_id_hash', $hash );
|
||||
gf_recaptcha()->log_debug( __METHOD__ . sprintf( '(): Using existing cached and validated assessment (ID: %s) for submitted hash (%s) for field #%d on form #%d.', $assessment['name'], $hash, $this->id, $this->formId ) );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
gf_recaptcha()->log_debug( __METHOD__ . sprintf( '(): Hash (%s) failed validation for field #%d on form #%d.', $hash, $this->id, $this->formId ) );
|
||||
}
|
||||
|
||||
gf_recaptcha()->log_debug( __METHOD__ . sprintf( '(): No token submitted for field #%d on form #%d.', $this->id, $this->formId ) );
|
||||
$this->set_required_error( '' );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! gf_recaptcha()->initialize_api() ) {
|
||||
gf_recaptcha()->log_debug( __METHOD__ . sprintf( '(): Skipping validation for field #%d on form #%d.', $this->id, $this->formId ) );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
gf_recaptcha()->log_debug( __METHOD__ . sprintf( '(): Validating token for field #%d on form #%d.', $this->id, $this->formId ) );
|
||||
|
||||
if ( ! $token_verifier->verify_submission( $token ) ) {
|
||||
$this->failed_validation = true;
|
||||
$this->validation_message = $this->errorMessage ?: __( 'The reCAPTCHA was invalid. Go back and try it again.', 'gravityformsrecaptcha' );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$assessment = $token_verifier->get_recaptcha_result();
|
||||
$assessment_id_hash = wp_hash( rgar( $assessment, 'name' ) );
|
||||
GFCache::set( $cache_key_prefix . $assessment_id_hash, $assessment, true, DAY_IN_SECONDS );
|
||||
$this->set_context_property( 'assessment_id_hash', $assessment_id_hash );
|
||||
gf_recaptcha()->log_debug( __METHOD__ . sprintf( '(): Caching validated assessment (ID: %s) using hash (%s).', $assessment['name'], $assessment_id_hash ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the assessment ID hash to the AJAX submission result when the response includes a page markup change.
|
||||
*
|
||||
* @since 2.2.0
|
||||
*
|
||||
* @param array $result The AJAX submission result.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function filter_ajax_submission_result( $result ) {
|
||||
$hash = $this->get_context_property( 'assessment_id_hash' );
|
||||
if ( ! $hash || ! isset( $result['page_markup'] ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$result['recaptcha_checkbox_response'] = $hash;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the cached assessment data after the entry has been saved.
|
||||
*
|
||||
* @since 2.2.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function action_entry_created() {
|
||||
$hash = $this->get_context_property( 'assessment_id_hash' );
|
||||
if ( ! $hash ) {
|
||||
return;
|
||||
}
|
||||
|
||||
GFCache::delete( sprintf( 'gf_recaptcha_assessment_%s_%d_', \GFFormsModel::get_form_unique_id( $this->formId ), $this->id ) . $hash );
|
||||
gf_recaptcha()->log_debug( __METHOD__ . sprintf( '(): Deleted assessment cache for hash (%s).', $hash ) );
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
\GF_Fields::register( new GF_Field_RECAPTCHA_Checkbox() );
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
namespace Gravity_Forms\Gravity_Forms_RECAPTCHA;
|
||||
|
||||
use GF_Field;
|
||||
use GFCommon;
|
||||
use GFAPI;
|
||||
|
||||
/**
|
||||
* Class GF_Field_RECAPTCHA
|
||||
*
|
||||
* @since 1.0
|
||||
*
|
||||
* @package Gravity_Forms\Gravity_Forms_RECAPTCHA
|
||||
*/
|
||||
class GF_Field_RECAPTCHA extends GF_Field {
|
||||
/**
|
||||
* Recaptcha field type.
|
||||
*
|
||||
* @since 1.0
|
||||
* @var string
|
||||
*/
|
||||
public $type = 'recaptcha';
|
||||
|
||||
/**
|
||||
* Prevent the field being saved to the entry.
|
||||
*
|
||||
* @since 1.1
|
||||
* @var bool
|
||||
*/
|
||||
public $displayOnly = true;
|
||||
|
||||
/**
|
||||
* Decoded field data.
|
||||
*
|
||||
* @since 1.0
|
||||
* @var object
|
||||
*/
|
||||
private $data;
|
||||
|
||||
/**
|
||||
* Return empty array to prevent the field from showing up in the form editor.
|
||||
*
|
||||
* @since 1.0
|
||||
* @return array
|
||||
*/
|
||||
public function get_form_editor_button() {
|
||||
return array();
|
||||
}
|
||||
|
||||
/**
|
||||
* The field markup.
|
||||
*
|
||||
* @since 1.0
|
||||
* @since 2.2.0 Updated not to output the input when a checkbox key is used.
|
||||
*
|
||||
* @param array $form The form array.
|
||||
* @param string $value The field value.
|
||||
* @param array|null $entry The entry array.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_field_input( $form, $value = '', $entry = null ) {
|
||||
if ( gf_recaptcha()->get_connection_type() === 'enterprise' ) {
|
||||
if ( ! gf_recaptcha()->enterprise_keys_configured() ) {
|
||||
gf_recaptcha()->log_error( __METHOD__ . '(): Enterprise project and/or key not saved in the reCAPTCHA Settings.' );
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
$key_type = gf_recaptcha()->get_plugin_settings_instance()->get_recaptcha_key( 'site_key_type_v3_enterprise' );
|
||||
$is_score_key = empty( $key_type ) || $key_type === 'SCORE';
|
||||
if ( ! $is_score_key ) {
|
||||
gf_recaptcha()->log_debug( __METHOD__ . '(): reCAPTCHA v3 Enterprise is not configured to use a score type key.' );
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
$site_key_attr = '';
|
||||
} else {
|
||||
$plugin_settings = gf_recaptcha()->get_plugin_settings_instance();
|
||||
$site_key_attr = sprintf( "data-sitekey='%s'", esc_attr( $plugin_settings->get_recaptcha_key( 'site_key_v3' ) ) );
|
||||
}
|
||||
|
||||
$this->formId = absint( rgar( $form, 'id' ) );
|
||||
$name = $this->get_input_name();
|
||||
$tabindex = GFCommon::$tab_index > 0 ? GFCommon::$tab_index ++ : 0;
|
||||
|
||||
return "<div class='gf_invisible ginput_recaptchav3' {$site_key_attr} data-tabindex='{$tabindex}'>"
|
||||
. '<input id="' . esc_attr( $name ) . '" class="gfield_recaptcha_response" type="hidden" name="' . esc_attr( $name ) . '" value=""/>'
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify the validation result if the Recaptcha response has been altered.
|
||||
*
|
||||
* This is a callback to the gform_validation filter to allow us to validate the values in the hidden field.
|
||||
*
|
||||
* @since 1.0
|
||||
*
|
||||
* @see GF_RECAPTCHA::init()
|
||||
*
|
||||
* @param array $validation_data The validation data.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function validation_check( $validation_data ) {
|
||||
$this->formId = absint( rgars( $validation_data, 'form/id' ) );
|
||||
|
||||
if ( $this->is_valid_field_data() ) {
|
||||
|
||||
// Set is_spam value.
|
||||
$validation_data['is_spam'] = gf_recaptcha()->is_spam_submission( rgar( $validation_data, 'form' ) );
|
||||
|
||||
return $validation_data;
|
||||
}
|
||||
|
||||
// Set is_valid to false and return the validation data.
|
||||
return $this->invalidate( $validation_data );
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the data in the hidden input is a valid Recaptcha entry.
|
||||
*
|
||||
* @since 1.0
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function is_valid_field_data() {
|
||||
$data = rgpost( $this->get_input_name() );
|
||||
|
||||
if ( empty( $data ) ) {
|
||||
gf_recaptcha()->log_debug( __METHOD__ . "(): Input {$this->get_input_name()} empty." );
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return gf_recaptcha()->get_token_verifier()->verify_submission( $data );
|
||||
}
|
||||
|
||||
/**
|
||||
* Set is_valid to false on the validation data.
|
||||
*
|
||||
* @since 1.0
|
||||
*
|
||||
* @param array $validation_data The validation data.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
private function invalidate( $validation_data ) {
|
||||
$validation_data['is_valid'] = false;
|
||||
|
||||
return $validation_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of the input name attribute.
|
||||
*
|
||||
* @since 1.1
|
||||
* @since 1.2 Added optional form_id parameter.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_input_name( $form_id = null ) {
|
||||
if ( $form_id ) {
|
||||
$this->formId = absint( $form_id );
|
||||
}
|
||||
|
||||
return 'input_' . md5( 'recaptchav3' . gf_recaptcha()->get_version() . $this->formId );
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
<?php
|
||||
/**
|
||||
* API wrapper for the Recaptcha service.
|
||||
*
|
||||
* @since 1.0
|
||||
* @package Gravity_Forms\Gravity_Forms_RECAPTCHA
|
||||
*/
|
||||
|
||||
namespace Gravity_Forms\Gravity_Forms_RECAPTCHA;
|
||||
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* Class RECAPTCHA_API
|
||||
*
|
||||
* @package Gravity_Forms\Gravity_Forms_RECAPTCHA
|
||||
*/
|
||||
class RECAPTCHA_API {
|
||||
/**
|
||||
* Google Recaptcha token verification URL.
|
||||
*
|
||||
* @since 1.0
|
||||
* @var string
|
||||
*/
|
||||
private $verification_url = 'https://www.google.com/recaptcha/api/siteverify';
|
||||
|
||||
/**
|
||||
* OAuth Access Token.
|
||||
*
|
||||
* @since 1.7.0
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $access_token;
|
||||
|
||||
/**
|
||||
* OAuth Refresh Token.
|
||||
*
|
||||
* @since 1.7.0
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $refresh_token;
|
||||
|
||||
/**
|
||||
* The GF_RECAPTCHA instance.
|
||||
*
|
||||
* @since 1.7.0
|
||||
*
|
||||
* @var GF_RECAPTCHA|null The GF_RECAPTCHA instance.
|
||||
*/
|
||||
protected $addon;
|
||||
|
||||
/**
|
||||
* The Google Cloud Project ID.
|
||||
*
|
||||
* @since 1.7.0
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $project_id;
|
||||
|
||||
/**
|
||||
* Constructor for RECAPTCHA_API
|
||||
*
|
||||
* @param string $auth_data The array of auth data.
|
||||
* @param GF_RECAPTCHA $addon The GF_RECAPTCHA instance.
|
||||
*/
|
||||
public function __construct( $auth_data = null, $addon = null ) {
|
||||
$this->addon = $addon;
|
||||
$this->refresh_token = rgar( $auth_data, 'refresh_token' );
|
||||
$this->access_token = rgar( $auth_data, 'access_token' );
|
||||
$this->project_id = rgar( $auth_data, 'project_id' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Gravity API URL for path.
|
||||
*
|
||||
* @since 1.7.0
|
||||
*
|
||||
* @param string $path Endpoint path.
|
||||
*
|
||||
* @return string URL for Gravity API endpoint.
|
||||
*/
|
||||
public static function get_gravity_api_url( $path = '' ) {
|
||||
|
||||
if ( '/' !== substr( $path, 0, 1 ) ) {
|
||||
$path = '/' . $path;
|
||||
}
|
||||
|
||||
return defined( 'GRAVITY_API_URL' ) ? GRAVITY_API_URL . '/auth/googlerecaptcha' . $path : self::$gravity_api_url . '/auth/googlerecaptcha' . $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base URL for the Recaptcha API.
|
||||
*
|
||||
* @since 1.7.0
|
||||
*
|
||||
* @param string $base_path The base path.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function get_base_url( $base_path = null ) {
|
||||
if ( $base_path ) {
|
||||
return $base_path;
|
||||
}
|
||||
|
||||
return 'https://recaptchaenterprise.googleapis.com/v1/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a request to the reCAPTCHA API.
|
||||
*
|
||||
* @param string $path The relative request path.
|
||||
* @param array $body The request body.
|
||||
* @param array $headers The request headers.
|
||||
* @param string $method The request method.
|
||||
* @param string $base_path The base API path.
|
||||
*
|
||||
* @since 1.7.0
|
||||
*
|
||||
* @return array|string|WP_Error
|
||||
*/
|
||||
private function make_request( $path = '', $body = array(), $headers = array(), $method = 'GET', $base_path = null ) {
|
||||
|
||||
gf_recaptcha()->log_debug( __METHOD__ . '(): Making request to: ' . $path );
|
||||
|
||||
// Build request URL.
|
||||
$request_url = $this->get_base_url( $base_path ) . $path;
|
||||
|
||||
$args = array(
|
||||
'method' => $method,
|
||||
/**
|
||||
* Filters if SSL verification should occur.
|
||||
*
|
||||
* @param bool $ssl_verify If the SSL certificate should be verified. Defaults to false.
|
||||
* @param string $request_url The request URL.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
'sslverify' => apply_filters( 'https_local_ssl_verify', false, $request_url ),
|
||||
/**
|
||||
* Sets the HTTP timeout, in seconds, for the request.
|
||||
*
|
||||
* @param int $timeout_value The timeout limit, in seconds. Defaults to 30.
|
||||
* @param string $request_url The request URL.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
'timeout' => apply_filters( 'http_request_timeout', 30, $request_url ),
|
||||
);
|
||||
|
||||
$args['headers'] = $headers;
|
||||
|
||||
if ( 'GET' === $method || 'POST' === $method ) {
|
||||
$args['body'] = empty( $body ) ? '' : $body;
|
||||
}
|
||||
|
||||
if ( 'POST' === $method ) {
|
||||
$args['body'] = wp_json_encode( $body );
|
||||
}
|
||||
|
||||
// Execute request.
|
||||
$response = wp_remote_request(
|
||||
$request_url,
|
||||
$args
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$response_body = wp_remote_retrieve_body( $response );
|
||||
$retrieved_response_code = wp_remote_retrieve_response_code( $response );
|
||||
|
||||
if ( 200 !== $retrieved_response_code ) {
|
||||
$error_message = rgars( $response_body, 'error/message', "Expected response code: 200. Returned response code: {$retrieved_response_code}." );
|
||||
$error_code = rgars( $response_body, 'error/errors/reason', 'google_recaptcha_api_error' );
|
||||
|
||||
gf_recaptcha()->log_error( __METHOD__ . '(): Unable to validate with the Google Cloud API: ' . $error_message );
|
||||
|
||||
return new WP_Error( $error_code, $error_message, $retrieved_response_code );
|
||||
}
|
||||
|
||||
return $response_body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the result of token verification from the Recaptcha API.
|
||||
*
|
||||
* @param string $token The token to verify.
|
||||
* @param string $secret The site's secret key.
|
||||
*
|
||||
* @return array|\WP_Error
|
||||
*/
|
||||
public function verify_token( $token, $secret ) {
|
||||
return wp_remote_post(
|
||||
$this->verification_url,
|
||||
array(
|
||||
'body' => array(
|
||||
'secret' => $secret,
|
||||
'response' => $token,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the reCAPTCHA enterprise assessment.
|
||||
*
|
||||
* @param string $access_token The OAuth access token.
|
||||
* @param string $project_id The Google Cloud Project ID.
|
||||
* @param string $token The reCAPTCHA token from the submission.
|
||||
* @param string $site_key The reCAPTCHA site key.
|
||||
* @param string $action Teh reCATPCHA action.
|
||||
*
|
||||
* @since 1.7.0
|
||||
*
|
||||
* @return array|mixed|WP_Error
|
||||
*/
|
||||
public function create_recaptcha_assessment( $access_token, $project_id, $token, $site_key, $action ) {
|
||||
|
||||
$url = 'https://recaptchaenterprise.googleapis.com/v1/projects/' . $project_id . '/assessments';
|
||||
|
||||
$payload = wp_json_encode(
|
||||
array(
|
||||
'event' => array(
|
||||
'token' => $token,
|
||||
'siteKey' => $site_key,
|
||||
'expectedAction' => $action,
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
$response = wp_remote_post(
|
||||
$url,
|
||||
array(
|
||||
'headers' => array(
|
||||
'Authorization' => 'Bearer ' . $access_token,
|
||||
'Content-Type' => 'application/json',
|
||||
),
|
||||
'body' => $payload,
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Annotates the assessment; informing Google that the submission was spam or ham.
|
||||
*
|
||||
* @since 2.0
|
||||
*
|
||||
* @param string $assessment_id The assessment ID
|
||||
* @param string $annotation The annotation to added to the assessment. Possible values: LEGITIMATE or FRAUDULENT.
|
||||
*
|
||||
* @return array|mixed|WP_Error
|
||||
*/
|
||||
public function annotate_assessment( $assessment_id, $annotation ) {
|
||||
$body = array(
|
||||
'annotation' => $annotation,
|
||||
);
|
||||
|
||||
$response = wp_remote_post(
|
||||
sprintf( 'https://recaptchaenterprise.googleapis.com/v1/%s:annotate', $assessment_id ),
|
||||
array(
|
||||
'headers' => array(
|
||||
'Authorization' => 'Bearer ' . $this->access_token,
|
||||
'Content-Type' => 'application/json',
|
||||
),
|
||||
'body' => wp_json_encode( $body ),
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body( $response );
|
||||
|
||||
return $body ? json_decode( $body, true ) : $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the reCAPTCHA access token.
|
||||
*
|
||||
* @param string $refresh_token The refresh token.
|
||||
*
|
||||
* @since 1.7.0
|
||||
*
|
||||
* @return array|WP_Error
|
||||
*/
|
||||
public function refresh_token( $refresh_token ) {
|
||||
// Connect to Gravity Form's API.
|
||||
$response = wp_remote_post(
|
||||
$this->get_gravity_api_url( 'refresh' ),
|
||||
array(
|
||||
'body' => array(
|
||||
'refresh_token' => rawurlencode( $refresh_token ),
|
||||
),
|
||||
)
|
||||
);
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$retrieved_response_code = wp_remote_retrieve_response_code( $response );
|
||||
if ( 200 !== $retrieved_response_code ) {
|
||||
$error_message = "Expected response code: 200. Returned response code: {$retrieved_response_code}.";
|
||||
|
||||
return new WP_Error( 'google_recaptcha_api_error', $error_message, $retrieved_response_code );
|
||||
}
|
||||
|
||||
$response_body = gf_recaptcha()->maybe_decode_json( wp_remote_retrieve_body( $response ) );
|
||||
|
||||
if ( array_key_exists( 'auth_error', $response_body ) ) {
|
||||
if ( empty( $response_body['auth_error'] ) ) {
|
||||
$error_message = 'Google returned an empty response.';
|
||||
} else {
|
||||
$error_message = 'Google response: ' . print_r( $response_body['auth_error'], true );
|
||||
}
|
||||
|
||||
return new WP_Error( 'google_recaptcha_api_error', $error_message );
|
||||
}
|
||||
|
||||
return $response_body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of Google Cloud projects.
|
||||
*
|
||||
* @since 1.7.0
|
||||
*
|
||||
* @return array|mixed
|
||||
*/
|
||||
public function get_recaptcha_projects() {
|
||||
|
||||
$request_path = 'projects';
|
||||
|
||||
$headers = array(
|
||||
'Authorization' => 'Bearer ' . $this->access_token,
|
||||
'Accept' => 'application/json',
|
||||
);
|
||||
|
||||
$response = $this->make_request( $request_path, array(), $headers, 'GET', 'https://cloudresourcemanager.googleapis.com/v1/' );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$data = json_decode( $response, true );
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of enterprise site keys associates with the chosen project.
|
||||
*
|
||||
* @param string $project The Google Cloud Project ID.
|
||||
*
|
||||
* @since 1.7.0
|
||||
* @since 1.8.0 Increased the default page size to 100 and added pagination to retrieve all keys.
|
||||
*
|
||||
* @return array|mixed
|
||||
*/
|
||||
public function get_enterprise_site_keys( $project ) {
|
||||
$request_path_base = 'projects/' . $project . '/keys';
|
||||
|
||||
$headers = array(
|
||||
'Authorization' => 'Bearer ' . $this->access_token,
|
||||
'Accept' => 'application/json',
|
||||
);
|
||||
|
||||
$all_keys = array(
|
||||
'keys' => array(),
|
||||
);
|
||||
$page_token = null;
|
||||
$page_size = apply_filters( 'gform_recaptcha_enterprise_keys_page_size', 100 );
|
||||
|
||||
$query_params = array(
|
||||
'pageSize' => $page_size,
|
||||
);
|
||||
|
||||
do {
|
||||
if ( $page_token ) {
|
||||
$query_params['pageToken'] = $page_token;
|
||||
}
|
||||
|
||||
$request_path = $request_path_base . '?' . http_build_query( $query_params );
|
||||
|
||||
$response = $this->make_request( $request_path, array(), $headers );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
$this->addon->log_error( __METHOD__ . '(): Unable to retrieve site keys: ' . $response->get_error_message() );
|
||||
break;
|
||||
}
|
||||
|
||||
$data = json_decode( $response, true );
|
||||
|
||||
if ( isset( $data['keys'] ) && is_array( $data['keys'] ) ) {
|
||||
$all_keys['keys'] = array_merge( $all_keys['keys'], $data['keys'] );
|
||||
}
|
||||
|
||||
$page_token = rgar( $data, 'nextPageToken' ) ? $data['nextPageToken'] : null;
|
||||
} while ( $page_token );
|
||||
|
||||
return $all_keys;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
namespace Gravity_Forms\Gravity_Forms_RECAPTCHA;
|
||||
|
||||
use GFCache, GFAddOn;
|
||||
|
||||
/**
|
||||
* Refresh token lock.
|
||||
*
|
||||
* Hosts the logic required to prevent repeated refresh token requests.
|
||||
*
|
||||
* @since 1.8.0
|
||||
*/
|
||||
class Refresh_Lock_Handler {
|
||||
|
||||
/**
|
||||
* How many seconds until the rate limiting cache lock is cleared.
|
||||
*
|
||||
* @since 1.8.0
|
||||
*
|
||||
* @var integer
|
||||
*/
|
||||
const RATE_LIMIT_CACHE_EXPIRATION_SECONDS = MINUTE_IN_SECONDS;
|
||||
|
||||
/**
|
||||
* How many failed refresh requests until the refresh is locked becayse of rate limiting.
|
||||
*
|
||||
* @since 1.8.0
|
||||
*
|
||||
* @var integer
|
||||
*/
|
||||
const RATE_LIMIT_FAILED_REQUEST_THRESHOLD = 3;
|
||||
|
||||
/**
|
||||
* How many seconds until the refresh in progress cache lock is cleared.
|
||||
*
|
||||
* @since 1.8.0
|
||||
*
|
||||
* @var integer
|
||||
*/
|
||||
const REFRESH_IN_PROGRESS_EXPIRATION_SECONDS = MINUTE_IN_SECONDS;
|
||||
|
||||
/**
|
||||
* Failed request count option key.
|
||||
*
|
||||
* @since 1.8.0
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $failed_requests_count_key = '';
|
||||
|
||||
/**
|
||||
* The refresh in progress cache key.
|
||||
*
|
||||
* @since 1.8.0
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $refresh_in_progress_lock_key = '';
|
||||
|
||||
/**
|
||||
* The rate limiting cache key.
|
||||
*
|
||||
* @since 1.8.0
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $rate_limit_lock_key = '';
|
||||
|
||||
/**
|
||||
* The reason why the refreshing could be locked.
|
||||
*
|
||||
* @since 1.8.0
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $refresh_lock_reason = '';
|
||||
|
||||
/**
|
||||
* An instance of the add-on.
|
||||
*
|
||||
* @since 1.8.0
|
||||
*
|
||||
* @var GFAddOn
|
||||
*/
|
||||
protected $addon;
|
||||
|
||||
/**
|
||||
* Handler constructor.
|
||||
*
|
||||
* @since 1.8.0
|
||||
*
|
||||
* @param GFAddOn $addon The add-on the handler is being initialized for.
|
||||
*/
|
||||
public function __construct( $addon ) {
|
||||
$this->addon = $addon;
|
||||
$slug = $addon->get_slug();
|
||||
$this->failed_requests_count_key = $slug . '_failed_refresh_token_requests_count';
|
||||
$this->refresh_in_progress_lock_key = $slug . '_refresh_lock';
|
||||
$this->rate_limit_lock_key = $slug . '_rate_limit';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the rate limit cache key is set, and sets the lock reason if locked.
|
||||
*
|
||||
* After consecutive failed requests, a cache key is set to prevent more failed requests.
|
||||
*
|
||||
* @sicne 1.8.0
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function is_rate_limited() {
|
||||
$rate_limited = GFCache::get( $this->rate_limit_lock_key, $found );
|
||||
if ( $found && $rate_limited ) {
|
||||
$this->refresh_lock_reason = 'Refresh token request rate limit reached.';
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the threshold for failed refresh requests has been reached, sets the cache key if so.
|
||||
*
|
||||
* @since 1.8.0
|
||||
*/
|
||||
public function increment_rate_limit() {
|
||||
$failed_requests_count = intval( get_option( $this->failed_requests_count_key ) );
|
||||
if ( $failed_requests_count >= self::RATE_LIMIT_FAILED_REQUEST_THRESHOLD ) {
|
||||
$this->addon->log_debug( __METHOD__ . '(): Rate limit threshold reached, setting rate limit lock.' );
|
||||
GFCache::set( $this->rate_limit_lock_key, true, true, self::RATE_LIMIT_CACHE_EXPIRATION_SECONDS );
|
||||
update_option( $this->failed_requests_count_key, 0 );
|
||||
} else {
|
||||
$this->addon->log_debug( __METHOD__ . '(): Increasing failed requests count, current count: ' . $failed_requests_count );
|
||||
update_option( $this->failed_requests_count_key, $failed_requests_count + 1 );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if there is a request already being made for refreshing the token.
|
||||
*
|
||||
* @since 1.8.0
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_locked() {
|
||||
$locked = GFCache::get( $this->refresh_in_progress_lock_key, $found );
|
||||
if ( $found && $locked ) {
|
||||
$this->refresh_lock_reason = 'Token Refresh is already in progress.';
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the refresh in progress cache lock.
|
||||
*
|
||||
* @since 1.8.0
|
||||
*/
|
||||
public function lock() {
|
||||
GFCache::set( $this->refresh_in_progress_lock_key, true, true, self::REFRESH_IN_PROGRESS_EXPIRATION_SECONDS );
|
||||
$this->addon->log_debug( __METHOD__ . '(): Refresh in progress lock has been set.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the rate limit lock.
|
||||
*
|
||||
* @since 1.8.0
|
||||
*/
|
||||
public function reset_rate_limit() {
|
||||
GFCache::delete( $this->rate_limit_lock_key );
|
||||
update_option( $this->failed_requests_count_key, 0 );
|
||||
$this->addon->log_debug( __METHOD__ . '(): Rate limit lock cleared.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the refresh in progress lock.
|
||||
*
|
||||
* @since 1.8.0
|
||||
*/
|
||||
public function release_lock() {
|
||||
GFCache::delete( $this->refresh_in_progress_lock_key );
|
||||
$this->addon->log_debug( __METHOD__ . '(): Refresh in progress lock cleared.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the token can be refreshed after making sure no locks are in place.
|
||||
*
|
||||
* @since 1.8.0
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function can_refresh_token() {
|
||||
return $this->is_rate_limited() === false && $this->is_locked() === false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
<?php
|
||||
/**
|
||||
* Class responsible for verifying tokens returned by Recaptcha.
|
||||
*
|
||||
* @package Gravity_Forms\Gravity_Forms_RECAPTCHA
|
||||
*/
|
||||
|
||||
namespace Gravity_Forms\Gravity_Forms_RECAPTCHA;
|
||||
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* Class Token_Verifier
|
||||
*
|
||||
* @since 1.0
|
||||
*
|
||||
* @package Gravity_Forms\Gravity_Forms_RECAPTCHA
|
||||
*/
|
||||
class Token_Verifier {
|
||||
/**
|
||||
* Error code returned if a token or secret is missing.
|
||||
*
|
||||
* @since 1.0
|
||||
*/
|
||||
const ERROR_CODE_MISSING_TOKEN_OR_SECRET = 'gravityformsrecaptcha-missing-token-or-secret';
|
||||
|
||||
/**
|
||||
* Error code returned if the token cannot be verified.
|
||||
*
|
||||
* @since 1.0
|
||||
*/
|
||||
const ERROR_CODE_CANNOT_VERIFY_TOKEN = 'gravityforms-cannot-verify-token';
|
||||
|
||||
/**
|
||||
* Instance of the add-on class.
|
||||
*
|
||||
* @since 1.0
|
||||
* @var GF_RECAPTCHA
|
||||
*/
|
||||
private $addon;
|
||||
|
||||
/**
|
||||
* Class instance.
|
||||
*
|
||||
* @since 1.0
|
||||
* @var RECAPTCHA_API
|
||||
*/
|
||||
private $api;
|
||||
|
||||
/**
|
||||
* Minimum score the Recaptcha API can return before a form submission is marked as spam.
|
||||
*
|
||||
* @since 1.0
|
||||
* @var float
|
||||
*/
|
||||
private $score_threshold;
|
||||
|
||||
/**
|
||||
* Token generated by the Recaptcha service that requires validation.
|
||||
*
|
||||
* @since 1.0
|
||||
* @var string
|
||||
*/
|
||||
private $token;
|
||||
|
||||
/**
|
||||
* Recaptcha application secret used to verify the token.
|
||||
*
|
||||
* @since 1.0
|
||||
* @var string
|
||||
*/
|
||||
private $secret;
|
||||
|
||||
/**
|
||||
* Result of the recaptcha request.
|
||||
*
|
||||
* @var stdClass|array
|
||||
*/
|
||||
private $recaptcha_result;
|
||||
|
||||
/**
|
||||
* The reCAPTCHA action.
|
||||
*
|
||||
* @since 1.4 Previously a dynamic property.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $action;
|
||||
|
||||
/**
|
||||
* The connection type.
|
||||
*
|
||||
* @since 1.7.0
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $connection_type = '';
|
||||
|
||||
/**
|
||||
* Token_Verifier constructor.
|
||||
*
|
||||
* @since 1.0
|
||||
*
|
||||
* @param GF_RECAPTCHA $addon Instance of the GF_RECAPTCHA add-on.
|
||||
* @param RECAPTCHA_API $api Instance of the Recaptcha API.
|
||||
*/
|
||||
public function __construct( GF_RECAPTCHA $addon, RECAPTCHA_API $api ) {
|
||||
$this->addon = $addon;
|
||||
$this->api = $api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes this object for use.
|
||||
*
|
||||
* @param string $token The reCAPTCHA token.
|
||||
* @param string $action The reCAPTCHA action.
|
||||
* @param string $connection_type The connection type.
|
||||
*
|
||||
* @since 1.0
|
||||
*/
|
||||
public function init( $token = '', $action = '', $connection_type = null ) {
|
||||
$this->token = $token;
|
||||
$this->action = $action;
|
||||
$this->secret = $this->addon->get_plugin_settings_instance()->get_recaptcha_key( 'secret_key_v3' );
|
||||
$this->score_threshold = $this->addon->get_plugin_setting( 'score_threshold_v3', 0.5 );
|
||||
$this->connection_type = $connection_type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the reCAPTCHA result.
|
||||
*
|
||||
* Returns a stdClass if it's already been processed.
|
||||
*
|
||||
* @since 1.0
|
||||
*
|
||||
* @return stdClass|array|null
|
||||
*/
|
||||
public function get_recaptcha_result() {
|
||||
return $this->recaptcha_result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populates the reCAPTCHA result with the given data.
|
||||
*
|
||||
* @since 2.2.0
|
||||
*
|
||||
* @param array $result The reCAPTCHA result.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function set_recaptcha_result( $result ) {
|
||||
$this->recaptcha_result = $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the reCAPTCHA response data has the required properties and meets expectations.
|
||||
*
|
||||
* @since 1.0
|
||||
* @since 1.7.0 Added support for enterprise reCAPTCHA.
|
||||
*
|
||||
* @param array $response_data The response data to validate.
|
||||
* @param string $connection_type The connection type.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function validate_response_data( $response_data, $connection_type = null ) {
|
||||
|
||||
if ( $connection_type === 'enterprise' ) {
|
||||
return $this->validate_enterprise_assessment_response( $response_data );
|
||||
} else {
|
||||
return $this->validate_classic_response( $response_data );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the enterprise assessment response.
|
||||
*
|
||||
* @param array $response_data The response data.
|
||||
*
|
||||
* @since 1.7.0
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function validate_enterprise_assessment_response( $response_data ) {
|
||||
if ( rgar( $response_data, 'error' )
|
||||
|| rgars( $response_data, 'tokenProperties/valid' ) !== true
|
||||
|| ! rgars( $response_data, 'riskAnalysis/score' )
|
||||
|| ! rgars( $response_data, 'tokenProperties/action' )
|
||||
|| ! rgars( $response_data, 'tokenProperties/hostname' )
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
rgars( $response_data, 'tokenProperties/valid' ) === true
|
||||
&& $this->verify_hostname( rgars( $response_data, 'tokenProperties/hostname' ) )
|
||||
&& $this->verify_action( rgars( $response_data, 'tokenProperties/action' ) )
|
||||
&& $this->verify_score( rgars( $response_data, 'riskAnalysis/score' ) )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the classic reCAPTCHA response.
|
||||
*
|
||||
* @param array $response_data The response data.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @since 1.7.0 Moved from the validate_response_data method.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function validate_classic_response( $response_data ) {
|
||||
if (
|
||||
! empty( $response_data->{'error-codes'} )
|
||||
|| ( property_exists( $response_data, 'success' ) && $response_data->success !== true )
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$validation_properties = array( 'hostname', 'action', 'success', 'score', 'challenge_ts' );
|
||||
$response_properties = array_filter(
|
||||
$validation_properties,
|
||||
function( $property ) use ( $response_data ) {
|
||||
return property_exists( $response_data, $property );
|
||||
}
|
||||
);
|
||||
|
||||
if ( count( $validation_properties ) !== count( $response_properties ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
$response_data->success
|
||||
&& $this->verify_hostname( $response_data->hostname )
|
||||
&& $this->verify_action( $response_data->action )
|
||||
&& $this->verify_score( $response_data->score )
|
||||
&& $this->verify_timestamp( $response_data->challenge_ts )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the submission data.
|
||||
*
|
||||
* @since 1.0
|
||||
*
|
||||
* @param string $token The Recapatcha token.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function verify_submission( $token ) {
|
||||
|
||||
$data = \GFCache::get( 'recaptcha_' . $token, $found );
|
||||
if ( $found ) {
|
||||
$this->addon->log_debug( __METHOD__ . '(): Using cached reCAPTCHA result: ' . print_r( $data, true ) ); // @codingStandardsIgnoreLine
|
||||
$this->recaptcha_result = $data;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->addon->log_debug( __METHOD__ . '(): Verifying reCAPTCHA submission.' );
|
||||
|
||||
if ( empty( $token ) ) {
|
||||
$this->addon->log_debug( __METHOD__ . '(): Could not verify the submission because no token was found.' . PHP_EOL );
|
||||
return false;
|
||||
}
|
||||
|
||||
$plugin_settings = $this->addon->get_plugin_settings();
|
||||
$connection_type = rgar( $plugin_settings, 'connection_type' );
|
||||
|
||||
$this->init( $token, 'submit', $connection_type );
|
||||
|
||||
if ( $connection_type !== 'enterprise' ) {
|
||||
$response = $this->get_response_data( $this->api->verify_token( $token, $this->addon->get_plugin_settings_instance()->get_recaptcha_key( 'secret_key_v3' ) ) );
|
||||
} else {
|
||||
$access_token = rgar( $plugin_settings, 'access_token' );
|
||||
$project_id = $this->addon->get_plugin_settings_instance()->get_recaptcha_key( 'project_number' );
|
||||
|
||||
$response = $this->api->create_recaptcha_assessment( $access_token, $project_id, $token, $this->addon->get_plugin_settings_instance()->get_recaptcha_key( 'site_key_v3_enterprise' ), $action = 'submit' );
|
||||
}
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
$this->addon->log_debug( __METHOD__ . '(): Validating the reCAPTCHA response has failed due to the following: ' . $response->get_error_message() );
|
||||
wp_send_json_error(
|
||||
array(
|
||||
'error' => $data->get_error_message(),
|
||||
'code' => self::ERROR_CODE_CANNOT_VERIFY_TOKEN,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if ( isset( $response->score ) && $response->score === 'disabled (quota limit)' ) {
|
||||
$this->addon->log_debug( __METHOD__ . '(): Validation bypassed due to reCAPTCHA quota limit.' );
|
||||
$this->recaptcha_result = $response;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if ( ! $this->validate_response_data( $response, $connection_type ) ) {
|
||||
$this->addon->log_debug(
|
||||
__METHOD__ . '(): Could not validate the token request from the reCAPTCHA service. ' . PHP_EOL
|
||||
. "token: {$token}" . PHP_EOL
|
||||
. "response: " . print_r( $response, true ) . PHP_EOL // @codingStandardsIgnoreLine
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// @codingStandardsIgnoreLine
|
||||
$this->addon->log_debug( __METHOD__ . '(): Validated reCAPTCHA: ' . print_r( $response, true ) );
|
||||
$this->recaptcha_result = $response;
|
||||
|
||||
// Caching result for 1 hour.
|
||||
\GFCache::set( 'recaptcha_' . $token, $response, true, 60 * 60 );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data from the response.
|
||||
*
|
||||
* @since 1.0
|
||||
*
|
||||
* @param WP_Error|string $response The response from the API request.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
private function get_response_data( $response ) {
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$response_code = wp_remote_retrieve_response_code( $response );
|
||||
|
||||
/**
|
||||
* If the reCAPTCHA API quota has been exceeded, a 429 status code
|
||||
* is returned. This will fake a successful response to prevent
|
||||
* the form from being blocked by a reCAPTCHA quota limit.
|
||||
*/
|
||||
|
||||
if ( $response_code === 429 ) {
|
||||
$this->addon->log_debug( __METHOD__ . '(): reCAPTCHA API quota limit exceeded.' );
|
||||
|
||||
update_option( GF_RECAPTCHA::RECAPTCHA_QUOTA_LIMIT_HIT, true );
|
||||
|
||||
$data = new stdClass;
|
||||
$data->success = true;
|
||||
$data->challenge_ts = date( 'c' );
|
||||
$data->hostname = wp_parse_url( get_home_url(), PHP_URL_HOST );
|
||||
$data->score = 'disabled (quota limit)';
|
||||
$data->action = $this->action;
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
return json_decode( wp_remote_retrieve_body( $response ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the reCAPTCHA hostname.
|
||||
*
|
||||
* @since 1.0
|
||||
*
|
||||
* @param string $hostname Verify that the host name returned matches the site.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function verify_hostname( $hostname ) {
|
||||
if ( ! has_filter( 'gform_recaptcha_valid_hostnames' ) ) {
|
||||
$this->addon->log_debug( __METHOD__ . '(): gform_recaptcha_valid_hostnames filter not implemented. Skipping.' );
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->addon->log_debug( __METHOD__ . '(): gform_recaptcha_valid_hostnames filter detected. Verifying hostname.' );
|
||||
|
||||
/**
|
||||
* Filter for the set of hostnames considered valid by this site.
|
||||
*
|
||||
* Google returns a 'hostname' value in reCAPTCHA verification results. We validate against this value to ensure
|
||||
* that the data is good. By default, we use only the WordPress installation's home URL, but have extended
|
||||
* this via a filter so developers can define an array of hostnames to allow.
|
||||
*
|
||||
* @since 1.0
|
||||
*
|
||||
* @param array $valid_hostnames {
|
||||
* An indexed array of valid hostname strings. Example:
|
||||
* array( 'example.com', 'another-example.com' )
|
||||
* }
|
||||
*/
|
||||
$valid_hostnames = apply_filters(
|
||||
'gform_recaptcha_valid_hostnames',
|
||||
array(
|
||||
wp_parse_url( get_home_url(), PHP_URL_HOST ),
|
||||
)
|
||||
);
|
||||
|
||||
return is_array( $valid_hostnames ) ? in_array( $hostname, $valid_hostnames, true ) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the reCAPTCHA action.
|
||||
*
|
||||
* @since 1.0
|
||||
*
|
||||
* @param string $action The reCAPTCHA result action.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function verify_action( $action ) {
|
||||
$this->addon->log_debug( __METHOD__ . '(): Verifying action from reCAPTCHA response.' );
|
||||
|
||||
return $this->action === $action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the score is valid.
|
||||
*
|
||||
* @since 1.0
|
||||
*
|
||||
* @param float $score The reCAPTCHA v3 score.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function verify_score( $score ) {
|
||||
$this->addon->log_debug( __METHOD__ . '(): Verifying score from reCAPTCHA response.' );
|
||||
|
||||
if ( $score === 'disabled (quota limit)' ) {
|
||||
$this->addon->log_debug( __METHOD__ . '(): Score verfication bypassed due to exceeding the reCAPTCHA API quota limit.' );
|
||||
return true;
|
||||
}
|
||||
|
||||
return ( is_numeric( $score ) && $score >= 0.0 && $score <= 1.0 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the timestamp of the submission is valid.
|
||||
*
|
||||
* Google allows a reCAPTCHA token to be valid for two minutes. On multi-page forms, we generate a new token with
|
||||
* the advancement of each page, but the timestamp that's returned is always the same. Thus, we'll allow a longer
|
||||
* time frame for form submissions before considering them to be invalid.
|
||||
*
|
||||
* @since 1.0
|
||||
*
|
||||
* @param string $challenge_ts The challenge timestamp from the reCAPTCHA service.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function verify_timestamp( $challenge_ts ) {
|
||||
$this->addon->log_debug( __METHOD__ . '(): Verifying timestamp from reCAPTCHA response.' );
|
||||
|
||||
return ( gmdate( time() ) - strtotime( $challenge_ts ) ) <= 24 * HOUR_IN_SECONDS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the score from the Recaptcha result.
|
||||
*
|
||||
* @since 1.0
|
||||
*
|
||||
* @return float
|
||||
*/
|
||||
public function get_score() {
|
||||
if ( empty( $this->recaptcha_result ) ||
|
||||
( ! rgars( $this->recaptcha_result, 'riskAnalysis/score' ) &&
|
||||
! property_exists( $this->recaptcha_result, 'score' ) )
|
||||
) {
|
||||
return $this->addon->is_preview() ? 0.9 : 0.0;
|
||||
}
|
||||
|
||||
if ( rgars( $this->recaptcha_result, 'riskAnalysis/score' ) ) {
|
||||
$score = rgars( $this->recaptcha_result, 'riskAnalysis/score' );
|
||||
} else {
|
||||
$score = $this->recaptcha_result->score;
|
||||
}
|
||||
|
||||
return (float) $score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the assessment ID (name) from the reCAPTCHA assessment response.
|
||||
*
|
||||
* @since 1.10
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function get_assessment_id() {
|
||||
return $this->addon->is_preview() ? '' : rgar( $this->recaptcha_result, 'name', '' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the decoded response data from the API.
|
||||
*
|
||||
* @param string $token The validation token.
|
||||
* @param string $secret The stored secret key from the settings page.
|
||||
*
|
||||
* @since 1.0
|
||||
*
|
||||
* @return WP_Error|mixed|string
|
||||
*/
|
||||
public function verify( $token, $secret ) {
|
||||
return $this->get_response_data( $this->api->verify_token( $token, $secret ) );
|
||||
}
|
||||
}
|
||||
+1105
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user