diff --git a/adminpages/securitysettings.php b/adminpages/securitysettings.php
index f444781ff..c00256815 100644
--- a/adminpages/securitysettings.php
+++ b/adminpages/securitysettings.php
@@ -18,6 +18,16 @@
// Save settings.
if( !empty( $_REQUEST['savesettings'] ) ) {
pmpro_setOption( "spamprotection", intval( $_POST['spamprotection'] ) );
+
+ // Save the captcha setting. Note: This must be saved before the
+ // pmpro_save_security_settings hook fires so that the captcha services
+ // saving their settings on that hook can see the updated value.
+ $captcha = isset( $_POST['captcha'] ) ? sanitize_text_field( $_POST['captcha'] ) : '';
+ if ( ! array_key_exists( $captcha, pmpro_get_captcha_services() ) ) {
+ $captcha = '';
+ }
+ pmpro_setOption( 'captcha', $captcha );
+
if ( isset( $_POST['use_ssl'] ) ) {
// REQUEST['use_ssl'] will not be set if the entire site is already over HTTPS.
pmpro_setOption( "use_ssl", intval( $_POST['use_ssl'] ) );
@@ -158,6 +168,14 @@ function pmpro_is_plugin_installed_or_active( $plugin_file ) {
),
'description' => sprintf( esc_html__( 'Block IPs from checkout and login if there are more than %d failures within %d minutes.', 'paid-memberships-pro' ), (int) PMPRO_SPAM_ACTION_NUM_LIMIT, (int) round( PMPRO_SPAM_ACTION_TIME_LIMIT / 60, 2 ) ),
),
+ array(
+ 'name' => 'captcha',
+ 'label' => __( 'Captcha', 'paid-memberships-pro' ),
+ 'type' => 'select',
+ 'value' => pmpro_captcha(),
+ 'options' => array( '' => __( 'No', 'paid-memberships-pro' ) ) + pmpro_get_captcha_services(),
+ 'description' => __( 'Protect your checkout, login, and password reset forms with a captcha challenge. On login and password reset forms, the captcha is only shown after a failed login attempt from the visitor\'s IP address.', 'paid-memberships-pro' ),
+ ),
array(
// The callbacks hooked here echo their own
rows, so give them a table.
'html' => function() {
diff --git a/includes/captcha.php b/includes/captcha.php
new file mode 100644
index 000000000..ae8778a72
--- /dev/null
+++ b/includes/captcha.php
@@ -0,0 +1,154 @@
+ label.
+ */
+function pmpro_get_captcha_services() {
+ /**
+ * Filter the available captcha services.
+ *
+ * Captcha integrations should register themselves here as slug => label.
+ * A registered service should also hook the login, password reset, and
+ * checkout display and validation hooks and gate its logic on
+ * pmpro_captcha() returning its slug.
+ *
+ * @since TBD
+ *
+ * @param array $services Captcha services as slug => label.
+ */
+ return apply_filters( 'pmpro_captcha_services', array() );
+}
+
+/**
+ * Get which captcha service is enabled, if any.
+ *
+ * @since TBD
+ *
+ * @return string The slug of the enabled captcha service, or an empty string if no captcha is enabled.
+ */
+function pmpro_captcha() {
+ $captcha = get_option( 'pmpro_captcha', false );
+
+ // Backwards compatibility with the separate reCAPTCHA and Turnstile settings
+ // used before the single captcha setting existed. Note: a saved value of ''
+ // means "No" was chosen and should not fall back to the old settings.
+ // These two legacy options are intentionally hardcoded here rather than run
+ // through the captcha services registry: this shim is about the past and
+ // will never need to cover additional services.
+ if ( false === $captcha ) {
+ if ( get_option( 'pmpro_recaptcha' ) ) {
+ // If both were enabled, reCAPTCHA takes priority.
+ $captcha = 'recaptcha';
+ } elseif ( get_option( 'pmpro_cloudflare_turnstile' ) ) {
+ $captcha = 'turnstile';
+ } else {
+ $captcha = '';
+ }
+ }
+
+ // Only return registered captcha services. If the enabled service is no
+ // longer registered (e.g. its plugin was deactivated), the site safely
+ // reverts to having no captcha.
+ if ( ! empty( $captcha ) && ! array_key_exists( $captcha, pmpro_get_captcha_services() ) ) {
+ $captcha = '';
+ }
+
+ return $captcha;
+}
+
+/**
+ * Check whether the current IP has recent failed login activity and should be
+ * shown a captcha challenge on login and password reset forms.
+ *
+ * Uses the spam activity tracking in includes/spam.php.
+ *
+ * @since TBD
+ *
+ * @return bool True if the current IP has recent failed login activity.
+ */
+function pmpro_captcha_has_recent_failed_login() {
+ $activity = pmpro_get_spam_activity();
+ return ! empty( $activity );
+}
+
+/**
+ * Get the error message to show when a captcha check fails on a login or password reset form.
+ *
+ * @since TBD
+ *
+ * @return string The error message. Escaped, may contain tags.
+ */
+function pmpro_captcha_failed_error_message() {
+ return wp_kses( __( 'Error: Captcha verification failed. Please try again.', 'paid-memberships-pro' ), array( 'strong' => array() ) );
+}
+
+/**
+ * Check whether the current request includes a pmpro_captcha_failed error code
+ * passed back to the login page in the URL.
+ *
+ * @since TBD
+ *
+ * @return bool True if the request includes a captcha failed error code.
+ */
+function pmpro_is_captcha_failed_request() {
+ $error_params = array( 'action', 'errors', 'error' );
+ foreach ( $error_params as $param ) {
+ if ( empty( $_REQUEST[ $param ] ) ) {
+ continue;
+ }
+
+ // The errors param may contain a comma-separated list of error codes.
+ $codes = explode( ',', sanitize_text_field( $_REQUEST[ $param ] ) );
+ if ( in_array( 'pmpro_captcha_failed', $codes, true ) ) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Show a message on the frontend login page when a captcha check failed.
+ *
+ * @since TBD
+ *
+ * @param string $message The message to show.
+ * @param string $msgt The message type.
+ * @return string $message The message to show.
+ */
+function pmpro_captcha_failed_login_message( $message, $msgt ) {
+ if ( pmpro_is_captcha_failed_request() ) {
+ $message = pmpro_captcha_failed_error_message();
+ }
+
+ return $message;
+}
+add_filter( 'pmpro_login_forms_handler_message', 'pmpro_captcha_failed_login_message', 10, 2 );
+
+/**
+ * Set the message type for the captcha failed message on the frontend login page.
+ *
+ * @since TBD
+ *
+ * @param string $msgt The message type.
+ * @return string $msgt The message type.
+ */
+function pmpro_captcha_failed_login_msgt( $msgt ) {
+ if ( pmpro_is_captcha_failed_request() ) {
+ $msgt = 'pmpro_error';
+ }
+
+ return $msgt;
+}
+add_filter( 'pmpro_login_forms_handler_msgt', 'pmpro_captcha_failed_login_msgt' );
diff --git a/includes/cloudflare-turnstile.php b/includes/cloudflare-turnstile.php
index 7b478db73..83485b4c4 100644
--- a/includes/cloudflare-turnstile.php
+++ b/includes/cloudflare-turnstile.php
@@ -3,13 +3,27 @@
* Logic for CloudFlare Turnstile.
*/
+/**
+ * Register Cloudflare Turnstile as an available captcha service.
+ *
+ * @since TBD
+ *
+ * @param array $services Captcha services as slug => label.
+ * @return array $services Captcha services as slug => label.
+ */
+function pmpro_cloudflare_turnstile_register_captcha_service( $services ) {
+ $services['turnstile'] = __( 'Cloudflare Turnstile', 'paid-memberships-pro' );
+ return $services;
+}
+add_filter( 'pmpro_captcha_services', 'pmpro_cloudflare_turnstile_register_captcha_service' );
+
/**
* Show CloudFlare Turnstile on the checkout page.
*/
function pmpro_cloudflare_turnstile_get_html() {
// If CloudFlare Turnstile is not enabled, bail.
- if ( empty( get_option( 'pmpro_cloudflare_turnstile' ) ) ) {
+ if ( 'turnstile' !== pmpro_captcha() ) {
return;
}
@@ -43,7 +57,7 @@ function pmpro_cloudflare_turnstile_validation( $okay ) {
}
// If CloudFlare Turnstile is not enabled, bail.
- if ( empty( get_option( 'pmpro_cloudflare_turnstile' ) ) ) {
+ if ( 'turnstile' !== pmpro_captcha() ) {
return $okay;
}
@@ -52,35 +66,51 @@ function pmpro_cloudflare_turnstile_validation( $okay ) {
return $okay;
}
- // If the Turnstile is not passed, show an error.
- if ( empty( $_POST['cf-turnstile-response'] ) ) {
- pmpro_setMessage( __( 'Please complete the security check.', 'paid-memberships-pro' ), 'pmpro_error' );
+ // Verify the turnstile token. If the check failed, show an error.
+ $valid = pmpro_cloudflare_turnstile_verify_token( pmpro_getParam( 'cf-turnstile-response' ) );
+ if ( true !== $valid ) {
+ pmpro_setMessage( $valid, 'pmpro_error' );
return false;
}
+ // Only remember successful validations.
+ pmpro_set_session_var( 'pmpro_cloudflare_turnstile_validated', true );
+ return $okay;
+}
+
+/**
+ * Verify a CloudFlare Turnstile response token.
+ *
+ * @since TBD
+ *
+ * @param string $token The cf-turnstile-response token to verify.
+ * @return true|string True if the token is valid, or an error message to display if not.
+ */
+function pmpro_cloudflare_turnstile_verify_token( $token ) {
+ // An empty token means the user did not complete the challenge.
+ if ( empty( $token ) ) {
+ return __( 'Please complete the security check.', 'paid-memberships-pro' );
+ }
+
// Verify the turnstile check.
$headers = array(
'body' => array(
'secret' => get_option( 'pmpro_cloudflare_turnstile_secret_key', '' ),
- 'response' => pmpro_getParam( 'cf-turnstile-response' ),
+ 'response' => $token,
),
);
$verify = wp_remote_post( 'https://challenges.cloudflare.com/turnstile/v0/siteverify', $headers );
$verify = wp_remote_retrieve_body( $verify );
$response = json_decode( $verify );
- // If the check failed, show an error.
+ // If the check failed, return an error message.
if ( empty( $response->success ) ) {
$error_messages = pmpro_cloudflare_turnstile_get_error_message();
- $error_code = $response->{'error-codes'}[0];
- $displayed_message = isset( $error_messages[ $error_code ] ) ? $error_messages[ $error_code ] : esc_html__( 'An error occurred while validating the security check.', 'paid-memberships-pro' );
-
- pmpro_setMessage( $displayed_message, 'pmpro_error' );
- $okay = false;
+ $error_code = isset( $response->{'error-codes'}[0] ) ? $response->{'error-codes'}[0] : '';
+ return isset( $error_messages[ $error_code ] ) ? $error_messages[ $error_code ] : esc_html__( 'An error occurred while validating the security check.', 'paid-memberships-pro' );
}
- pmpro_set_session_var( 'pmpro_cloudflare_turnstile_validated', true );
- return $okay;
+ return true;
}
add_action( 'pmpro_checkout_checks', 'pmpro_cloudflare_turnstile_validation' );
add_action( 'pmpro_billing_update_checks', 'pmpro_cloudflare_turnstile_validation' );
@@ -92,33 +122,17 @@ function pmpro_cloudflare_turnstile_validation( $okay ) {
*/
function pmpro_cloudflare_turnstile_settings() {
// Get the options
- $cloudflare_turnstile = get_option( 'pmpro_cloudflare_turnstile', '0' );
$cloudflare_site_key = get_option( 'pmpro_cloudflare_turnstile_site_key', '' );
$cloudflare_secret_key = get_option( 'pmpro_cloudflare_turnstile_secret_key', '' );
$cloudflare_turnstile_depends = array(
array(
- 'id' => 'cloudflare_turnstile',
- 'value' => '1',
+ 'id' => 'captcha',
+ 'value' => 'turnstile',
),
);
// Output settings
- pmpro_build_settings_field( array(
- 'name' => 'cloudflare_turnstile',
- 'label' => __( 'Use CloudFlare Turnstile?', 'paid-memberships-pro' ),
- 'type' => 'select',
- 'value' => $cloudflare_turnstile,
- 'options' => array(
- '0' => __( 'No', 'paid-memberships-pro' ),
- '1' => __( 'Yes', 'paid-memberships-pro' ),
- ),
- 'description' => sprintf(
- /* translators: %s: Link to CloudFlare Turnstile. */
- __( 'A free CloudFlare Turnstile key is required. Click here to signup for CloudFlare Turnstile.', 'paid-memberships-pro' ),
- 'https://www.cloudflare.com/products/turnstile/'
- ),
- ) );
pmpro_build_settings_field( array(
'name' => 'cloudflare_turnstile_site_key',
'label' => __( 'Turnstile Site Key', 'paid-memberships-pro' ),
@@ -127,6 +141,11 @@ function pmpro_cloudflare_turnstile_settings() {
'value' => $cloudflare_site_key,
'row_class' => 'pmpro_cloudflare_turnstile_settings',
'depends' => $cloudflare_turnstile_depends,
+ 'description' => sprintf(
+ /* translators: %s: Link to CloudFlare Turnstile. */
+ __( 'A free CloudFlare Turnstile key is required. Click here to signup for CloudFlare Turnstile.', 'paid-memberships-pro' ),
+ 'https://www.cloudflare.com/products/turnstile/'
+ ),
) );
pmpro_build_settings_field( array(
'name' => 'cloudflare_turnstile_secret_key',
@@ -146,7 +165,8 @@ function pmpro_cloudflare_turnstile_settings() {
* @since 3.2
*/
function pmpro_cloudflare_turnstile_settings_save() {
- pmpro_setOption( 'cloudflare_turnstile', intval( $_POST['cloudflare_turnstile'] ) );
+ // Keep the legacy on/off option in sync with the captcha setting for backwards compatibility.
+ pmpro_setOption( 'cloudflare_turnstile', 'turnstile' === pmpro_captcha() ? 1 : 0 );
pmpro_setOption( 'cloudflare_turnstile_site_key', sanitize_text_field( $_POST['cloudflare_turnstile_site_key'] ) );
pmpro_setOption( 'cloudflare_turnstile_secret_key', sanitize_text_field( $_POST['cloudflare_turnstile_secret_key'] ) );
}
@@ -180,3 +200,92 @@ function pmpro_after_checkout_reset_cloudflare_turnstile() {
}
add_action( 'pmpro_after_checkout', 'pmpro_after_checkout_reset_cloudflare_turnstile' );
add_action( 'pmpro_after_update_billing', 'pmpro_after_checkout_reset_cloudflare_turnstile' );
+
+/**
+ * Check whether login and password reset forms should be challenged with Turnstile.
+ *
+ * @since TBD
+ *
+ * @return bool True if forms should be challenged.
+ */
+function pmpro_cloudflare_turnstile_should_challenge_login() {
+ // Only challenge if Turnstile is the active captcha service.
+ if ( 'turnstile' !== pmpro_captcha() ) {
+ return false;
+ }
+
+ // Don't challenge without keys. We couldn't render or verify the captcha,
+ // and requiring a check that can't be completed would lock users out.
+ if ( ! get_option( 'pmpro_cloudflare_turnstile_site_key' ) || ! get_option( 'pmpro_cloudflare_turnstile_secret_key' ) ) {
+ return false;
+ }
+
+ // Only challenge IPs that have recently failed to log in.
+ return pmpro_captcha_has_recent_failed_login();
+}
+
+/**
+ * Show Turnstile on PMPro and WP core login and lost password forms
+ * once a failed login attempt has been tracked for the current IP.
+ *
+ * @since TBD
+ */
+function pmpro_cloudflare_turnstile_login_forms_html() {
+ if ( ! pmpro_cloudflare_turnstile_should_challenge_login() ) {
+ return;
+ }
+
+ pmpro_cloudflare_turnstile_get_html();
+}
+add_action( 'pmpro_login_form_before_submit_button', 'pmpro_cloudflare_turnstile_login_forms_html' );
+add_action( 'pmpro_lost_password_before_submit_button', 'pmpro_cloudflare_turnstile_login_forms_html' );
+add_action( 'login_form', 'pmpro_cloudflare_turnstile_login_forms_html' );
+add_action( 'lostpassword_form', 'pmpro_cloudflare_turnstile_login_forms_html' );
+
+/**
+ * Require a valid Turnstile token on login attempts once the current IP has failed a login.
+ *
+ * @since TBD
+ *
+ * @param WP_User|WP_Error|null $user WP_User if the login is valid so far, otherwise WP_Error or null.
+ * @param string $username The username being used to log in.
+ * @return WP_User|WP_Error|null $user
+ */
+function pmpro_cloudflare_turnstile_login_check( $user, $username ) {
+ if ( ! pmpro_cloudflare_turnstile_should_challenge_login() ) {
+ return $user;
+ }
+
+ if ( true !== pmpro_cloudflare_turnstile_verify_token( pmpro_getParam( 'cf-turnstile-response' ) ) ) {
+ return new WP_Error( 'pmpro_captcha_failed', pmpro_captcha_failed_error_message() );
+ }
+
+ return $user;
+}
+add_filter( 'pmpro_authenticate_login_checks', 'pmpro_cloudflare_turnstile_login_check', 10, 2 );
+
+/**
+ * Require a valid Turnstile token on lost password submissions once the current IP has failed a login.
+ *
+ * @since TBD
+ *
+ * @param WP_Error $errors Error object to add a captcha error to.
+ * @param WP_User|false $user_data WP_User object if found, false if the user does not exist.
+ */
+function pmpro_cloudflare_turnstile_lostpassword_check( $errors, $user_data ) {
+ // Only check submissions from the PMPro or wp-login.php lost password forms. This hook
+ // also fires for other plugins that call retrieve_password() from their own forms,
+ // which never displayed our captcha.
+ if ( empty( $_REQUEST['pmpro_login_form_used'] ) && ! did_action( 'login_form_lostpassword' ) && ! did_action( 'login_form_retrievepassword' ) ) {
+ return;
+ }
+
+ if ( ! pmpro_cloudflare_turnstile_should_challenge_login() ) {
+ return;
+ }
+
+ if ( true !== pmpro_cloudflare_turnstile_verify_token( pmpro_getParam( 'cf-turnstile-response' ) ) ) {
+ $errors->add( 'pmpro_captcha_failed', pmpro_captcha_failed_error_message() );
+ }
+}
+add_action( 'lostpassword_post', 'pmpro_cloudflare_turnstile_lostpassword_check', 10, 2 );
diff --git a/includes/login.php b/includes/login.php
index 2795f2c74..5baefea78 100644
--- a/includes/login.php
+++ b/includes/login.php
@@ -244,6 +244,33 @@ function pmpro_login_form_hidden_field( $html ) {
return $html;
}
+/**
+ * Add content before the submit button on our login form.
+ * Hooks into the WP core filter login_form_middle. This filter is
+ * added right before wp_login_form() is called in pmpro_login_form()
+ * and removed right after so that it only runs on PMPro login forms.
+ *
+ * @since TBD
+ *
+ * @param string $content Content to display. Default empty.
+ * @param array $args Array of login form arguments.
+ * @return string $content Content to display.
+ */
+function pmpro_login_form_middle( $content, $args ) {
+ ob_start();
+
+ /**
+ * Fires before the submit button on the PMPro login form.
+ *
+ * @since TBD
+ *
+ * @param array $args Array of login form arguments.
+ */
+ do_action( 'pmpro_login_form_before_submit_button', $args );
+
+ return $content . ob_get_clean();
+}
+
/**
* Filter the_title based on the form action of the Log In Page assigned to $pmpro_pages['login'].
*
@@ -457,6 +484,28 @@ function pmpro_login_forms_handler( $show_menu = true, $show_logout_link = true,
}
}
+ /**
+ * Filter the message shown above the frontend login form.
+ * Allows custom messages to be shown for custom error codes
+ * passed back to the login page in the URL.
+ *
+ * @since TBD
+ *
+ * @param string $message The message to show. Empty string if no message.
+ * @param string $msgt The message type, e.g. pmpro_error, pmpro_success, pmpro_alert.
+ */
+ $message = apply_filters( 'pmpro_login_forms_handler_message', $message, $msgt );
+
+ /**
+ * Filter the type of the message shown above the frontend login form.
+ *
+ * @since TBD
+ *
+ * @param string $msgt The message type, e.g. pmpro_error, pmpro_success, pmpro_alert.
+ * @param string $message The message to show. Empty string if no message.
+ */
+ $msgt = apply_filters( 'pmpro_login_forms_handler_msgt', $msgt, $message );
+
ob_start();
?>
@@ -598,6 +647,7 @@ function pmpro_login_forms_handler( $show_menu = true, $show_logout_link = true,
function pmpro_login_form( $args = array() ) {
static $pmpro_login_form_counter = 1;
add_filter( 'login_form_top', 'pmpro_login_form_hidden_field' );
+ add_filter( 'login_form_middle', 'pmpro_login_form_middle', 10, 2 );
wp_login_form( $args );
?>
@@ -649,6 +699,7 @@ function togglePassword() {
'. esc_html( $message ) .'
';
- echo wp_kses_post( pmpro_lost_password_form() );
+ pmpro_lost_password_form(); // This function echoes the form directly.
return;
}
@@ -1022,25 +1073,63 @@ function pmpro_authenticate_username_password( $user, $username, $password ) {
// check what page the login attempt is coming from
$referrer = wp_get_referer();
- if ( !empty( $referrer ) && is_wp_error( $user ) ) {
+ if ( ! empty( $referrer ) && is_wp_error( $user ) ) {
$error = $user->get_error_code();
- if ( $error ) {
- $error_args = array(
- 'action' => urlencode( $error ),
- 'username' => urlencode( sanitize_text_field( $username ) )
- );
- wp_redirect( add_query_arg( $error_args, pmpro_login_url() ) );
- } else {
- wp_redirect( pmpro_login_url() );
- }
+ // Only redirect here for error codes that WP core "ignores" and won't fire wp_login_failed for.
+ // All other failed logins are redirected by pmpro_login_failed() hooked to wp_login_failed.
+ if ( in_array( $error, array( 'empty_username', 'empty_password' ), true ) ) {
+ $error_args = array(
+ 'action' => urlencode( $error ),
+ 'username' => urlencode( sanitize_text_field( $username ) )
+ );
+ wp_redirect( add_query_arg( $error_args, pmpro_login_url() ) );
+ exit;
+ }
}
return $user;
}
add_filter( 'authenticate', 'pmpro_authenticate_username_password', 30, 3);
+/**
+ * Allow custom checks (e.g. captchas) for login attempts made from the
+ * PMPro login form or the WP default login form on wp-login.php.
+ *
+ * @since TBD
+ *
+ * @param WP_User|WP_Error|null $user WP_User if the login is valid so far, otherwise WP_Error or null.
+ * @param string $username The username being used to log in.
+ * @param string $password The password being used to log in.
+ * @return WP_User|WP_Error|null $user
+ */
+function pmpro_apply_custom_login_checks( $user, $username, $password ) {
+ // Bail if no login was attempted. wp-login.php runs the authenticate filter on every page load.
+ if ( empty( $username ) && empty( $password ) ) {
+ return $user;
+ }
+
+ // Only run checks for login attempts from the PMPro login form or the form on wp-login.php.
+ // Other login flows (XML-RPC, other plugins' login forms, direct wp_signon() calls, etc.) are intentionally not affected.
+ if ( empty( $_REQUEST['pmpro_login_form_used'] ) && ! did_action( 'login_form_login' ) ) {
+ return $user;
+ }
+
+ /**
+ * Allow custom checks for login attempts from the PMPro login form or wp-login.php.
+ * Runs after WP core has checked the user's credentials.
+ * Return a WP_Error to block the login and show that error's message.
+ *
+ * @since TBD
+ *
+ * @param WP_User|WP_Error|null $user WP_User if the login is valid so far, otherwise WP_Error or null.
+ * @param string $username The username being used to log in.
+ */
+ return apply_filters( 'pmpro_authenticate_login_checks', $user, $username );
+}
+add_filter( 'authenticate', 'pmpro_apply_custom_login_checks', 40, 3 );
+
/**
* Redirect failed login to referrer for frontend user login.
*
diff --git a/includes/recaptcha.php b/includes/recaptcha.php
index 400070a39..af18ddc15 100644
--- a/includes/recaptcha.php
+++ b/includes/recaptcha.php
@@ -1,4 +1,18 @@
label.
+ * @return array $services Captcha services as slug => label.
+ */
+function pmpro_recaptcha_register_captcha_service( $services ) {
+ $services['recaptcha'] = __( 'Google reCAPTCHA', 'paid-memberships-pro' );
+ return $services;
+}
+add_filter( 'pmpro_captcha_services', 'pmpro_recaptcha_register_captcha_service' );
+
/**
* Sets up our JS code to validate ReCAPTCHA on form submission if needed.
*/
@@ -7,7 +21,7 @@ function pmpro_init_recaptcha() {
// global $recaptcha for backwards compatibility.
// TODO: Remove this in a future version.
global $recaptcha;
- $recaptcha = get_option( 'pmpro_recaptcha' );
+ $recaptcha = ( 'recaptcha' === pmpro_captcha() ) ? 2 : false;
if ( empty( $recaptcha ) ) {
return;
}
@@ -68,7 +82,7 @@ function pmpro_recaptcha_get_html() {
}
// If ReCAPTCHA is not enabled, bail.
- if ( empty( get_option( 'pmpro_recaptcha' ) ) ) {
+ if ( 'recaptcha' !== pmpro_captcha() ) {
return;
}
@@ -218,7 +232,7 @@ function pmpro_recaptcha_validation_check( $continue = true ) {
}
// If ReCAPTCHA is not enabled, return.
- if ( empty( get_option( 'pmpro_recaptcha' ) ) ) {
+ if ( 'recaptcha' !== pmpro_captcha() ) {
return true;
}
@@ -242,34 +256,17 @@ function pmpro_recaptcha_validation_check( $continue = true ) {
*/
function pmpro_recaptcha_settings() {
// Get the current options.
- $recaptcha = get_option( 'pmpro_recaptcha' );
$recaptcha_version = get_option( 'pmpro_recaptcha_version' );
$recaptcha_publickey = get_option( 'pmpro_recaptcha_publickey' );
$recaptcha_privatekey = get_option( 'pmpro_recaptcha_privatekey' );
- $recaptcha_value = $recaptcha > 0 ? 2 : 0;
$recaptcha_depends = array(
array(
- 'id' => 'recaptcha',
- 'value' => '2',
+ 'id' => 'captcha',
+ 'value' => 'recaptcha',
),
);
- pmpro_build_settings_field( array(
- 'name' => 'recaptcha',
- 'label' => __( 'Use reCAPTCHA?', 'paid-memberships-pro' ),
- 'type' => 'select',
- 'value' => $recaptcha_value,
- 'options' => array(
- 0 => __( 'No', 'paid-memberships-pro' ),
- 2 => __( 'Yes - All memberships.', 'paid-memberships-pro' ),
- ),
- 'description' => sprintf(
- /* translators: %s: Link to create a Google reCAPTCHA key. */
- __( 'A free reCAPTCHA key is required. Click here to signup for reCAPTCHA.', 'paid-memberships-pro' ),
- 'https://www.google.com/recaptcha/admin/create'
- ),
- ) );
pmpro_build_settings_field( array(
'name' => 'recaptcha_version',
'label' => __( 'reCAPTCHA Version', 'paid-memberships-pro' ),
@@ -281,7 +278,11 @@ function pmpro_recaptcha_settings() {
'2_checkbox' => __( 'v2 - Checkbox', 'paid-memberships-pro' ),
'3_invisible' => __( 'v3 - Invisible', 'paid-memberships-pro' ),
),
- 'description' => __( 'Changing your version will require new API keys.', 'paid-memberships-pro' ),
+ 'description' => sprintf(
+ /* translators: %s: Link to create a Google reCAPTCHA key. */
+ __( 'Changing your version will require new API keys. A free reCAPTCHA key is required. Click here to signup for reCAPTCHA.', 'paid-memberships-pro' ),
+ 'https://www.google.com/recaptcha/admin/create'
+ ),
) );
pmpro_build_settings_field( array(
'name' => 'recaptcha_publickey',
@@ -310,9 +311,197 @@ function pmpro_recaptcha_settings() {
* @since 3.2
*/
function pmpro_recaptcha_settings_save() {
- pmpro_setOption( "recaptcha", intval( $_POST['recaptcha'] ) );
+ // Keep the legacy on/off option in sync with the captcha setting for backwards compatibility.
+ pmpro_setOption( "recaptcha", 'recaptcha' === pmpro_captcha() ? 2 : 0 );
pmpro_setOption( "recaptcha_version", sanitize_text_field( $_POST['recaptcha_version'] ) );
pmpro_setOption( "recaptcha_publickey", sanitize_text_field( $_POST['recaptcha_publickey'] ) );
pmpro_setOption( "recaptcha_privatekey", sanitize_text_field( $_POST['recaptcha_privatekey'] ) );
}
add_action( 'pmpro_save_security_settings', 'pmpro_recaptcha_settings_save' );
+
+/**
+ * Check whether login and password reset forms should be challenged with reCAPTCHA.
+ *
+ * @since TBD
+ *
+ * @return bool True if forms should be challenged.
+ */
+function pmpro_recaptcha_should_challenge_login() {
+ // Only challenge if reCAPTCHA is the active captcha service.
+ if ( 'recaptcha' !== pmpro_captcha() ) {
+ return false;
+ }
+
+ // Don't challenge without keys. We couldn't render or verify the captcha,
+ // and requiring a check that can't be completed would lock users out.
+ if ( ! get_option( 'pmpro_recaptcha_publickey' ) || ! get_option( 'pmpro_recaptcha_privatekey' ) ) {
+ return false;
+ }
+
+ // Only challenge IPs that have recently failed to log in.
+ return pmpro_captcha_has_recent_failed_login();
+}
+
+/**
+ * Verify a reCAPTCHA response token with Google.
+ *
+ * @since TBD
+ *
+ * @param string $token The g-recaptcha-response token to verify.
+ * @return bool True if the token is valid.
+ */
+function pmpro_recaptcha_verify_token( $token ) {
+ // An empty token means the user did not complete the challenge.
+ if ( empty( $token ) ) {
+ return false;
+ }
+
+ require_once( PMPRO_DIR . '/includes/lib/recaptchalib.php' );
+ $reCaptcha = new pmpro_ReCaptcha( get_option( 'pmpro_recaptcha_privatekey' ) );
+ $resp = $reCaptcha->verifyResponse( pmpro_get_ip(), sanitize_text_field( $token ) );
+
+ return ! empty( $resp->success );
+}
+
+/**
+ * Outputs the HTML needed to display reCAPTCHA in login and password reset forms.
+ *
+ * Unlike pmpro_recaptcha_get_html(), this does not use the checkout JS or the
+ * AJAX/session validation flow. The token is submitted with the form and
+ * verified server-side when the submission is processed.
+ *
+ * @since TBD
+ */
+function pmpro_recaptcha_get_login_html() {
+ static $already_shown = false;
+
+ // Make sure that we only show the captcha once per page.
+ if ( $already_shown ) {
+ return;
+ }
+
+ $recaptcha_publickey = get_option( 'pmpro_recaptcha_publickey' );
+
+ // Figure out language.
+ $locale = get_locale();
+ if ( ! empty( $locale ) ) {
+ $parts = explode( '_', $locale );
+ $lang = $parts[0];
+ } else {
+ $lang = 'en';
+ }
+ /** This filter is documented in includes/recaptcha.php */
+ $lang = apply_filters( 'pmpro_recaptcha_lang', $lang );
+
+ // Check which version of reCAPTCHA we are using.
+ $recaptcha_version = get_option( 'pmpro_recaptcha_version' );
+ ?>
+
+ add( 'pmpro_captcha_failed', pmpro_captcha_failed_error_message() );
+ }
+}
+add_action( 'lostpassword_post', 'pmpro_recaptcha_lostpassword_check', 10, 2 );
diff --git a/includes/spam.php b/includes/spam.php
index 79d5f97f4..b78a2371c 100644
--- a/includes/spam.php
+++ b/includes/spam.php
@@ -231,9 +231,10 @@ function pmpro_check_discount_code_spam_check( $okay, $dbcode ) {
* @param string $username The username that failed to login.
*/
function pmpro_track_login_spam( $username ) {
- // Bail if Spam Protection is disabled.
+ // Bail if Spam Protection is disabled, unless a captcha is enabled.
+ // Captchas use this failed login activity to decide when to show a challenge.
$spamprotection = get_option( 'pmpro_spamprotection' );
- if ( empty( $spamprotection ) ) {
+ if ( empty( $spamprotection ) && empty( pmpro_captcha() ) ) {
return;
}
diff --git a/paid-memberships-pro.php b/paid-memberships-pro.php
index 7a28a232d..306822e61 100644
--- a/paid-memberships-pro.php
+++ b/paid-memberships-pro.php
@@ -133,6 +133,7 @@
require_once( PMPRO_DIR . '/includes/email-logging.php' ); // email logging functionality
require_once( PMPRO_DIR . '/includes/fields.php' ); // user fields
require_once( PMPRO_DIR . '/includes/settings-fields.php' ); // render-only helpers for admin settings sections
+require_once( PMPRO_DIR . '/includes/captcha.php' ); // shared code for captcha services
require_once( PMPRO_DIR . '/includes/recaptcha.php' ); // load recaptcha files if needed
require_once( PMPRO_DIR . '/includes/cloudflare-turnstile.php' ); // load CloudFlare Turnstile files if needed
require_once( PMPRO_DIR . '/includes/terms-of-service.php' ); // code to add a terms of service checkbox to checkout