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