initial
This commit is contained in:
@@ -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 ) );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user