action( 'init', array( $this, 'recaptcha_security' ) );
}
/**
* Boot feature if enabled and keys are provided.
*/
public function recaptcha_security() {
$this->settings = $this->get_settings();
if ( !$this->is_enabled( $this->settings ) ) {
return;
}
// Skip the two-factor code screen: the credentials (and therefore the
// captcha) were already verified in the first step, and the OTP form is
// handled on login_init before `authenticate` runs, so a widget shown
// here would never be checked.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only query var, no state change.
if ( isset( $_GET['tpsa_2fa'] ) || isset( $_POST['tpsa_2fa_token'] ) ) {
return;
}
if ( empty( $this->settings['site-key'] ) || empty( $this->settings['secret-key'] ) ) {
add_action( 'login_form', array( $this, 'show_recaptcha_error' ) );
add_action( 'register_form', array( $this, 'show_recaptcha_error' ) );
return;
}
/**
* Default WordPress login & registration
*/
$this->action( 'login_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
$this->action( 'login_form', array( $this, 'show_recaptcha' ) );
$this->action( 'register_form', array( $this, 'show_recaptcha' ) );
// Priority 26: after core (20) and after the lockout check (25), so a
// blocked address never costs an outbound call to Google, and before
// two-factor (30) so no code is emailed for a failed captcha.
$this->filter( 'authenticate', array( $this, 'verify_recaptcha_on_login' ), 26, 3 );
$this->filter( 'registration_errors', array( $this, 'verify_recaptcha_on_register' ), 10, 1 );
/**
* WooCommerce login & registration
*/
if ( class_exists( 'WooCommerce' ) ) {
// Enqueue scripts on frontend as well (for My Account / Woo forms).
$this->action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
// Output reCAPTCHA box in Woo login & register forms.
$this->action( 'woocommerce_login_form', array( $this, 'show_recaptcha' ) );
$this->action( 'woocommerce_register_form', array( $this, 'show_recaptcha' ) );
// Validate on Woo login.
$this->filter(
'woocommerce_process_login_errors',
array( $this, 'wc_verify_recaptcha_on_login' ),
10,
3
);
// Validate on Woo registration.
$this->filter(
'woocommerce_process_registration_errors',
array( $this, 'wc_verify_recaptcha_on_register' ),
10,
4
);
}
}
/**
* Enqueue Google reCAPTCHA script.
*/
public function enqueue_scripts() {
$site_key = isset( $this->settings['site-key'] ) ? (string) $this->settings['site-key'] : '';
$site_key = sanitize_text_field( $site_key );
$version = isset( $this->settings['version'] ) ? sanitize_key( $this->settings['version'] ) : 'v2';
if ( 'v3' === $version ) {
wp_enqueue_script(
'google-recaptcha-v3',
'https://www.google.com/recaptcha/api.js?render=' . rawurlencode( $site_key ),
array(),
null,
true
);
// Escape site key for safe JS string usage.
$site_key_js = esc_js( $site_key );
// Only the authentication forms get a token. The previous version
// injected a hidden g-recaptcha-response into EVERY form on the
// page — search, comments, newsletter signups, other plugins'
// forms — which is both wasteful and a good way to break them.
//
// The token also expires after two minutes, so it is refreshed on a
// timer and again right before submit; otherwise anyone who pauses
// on the login screen gets an unexplained "verification failed".
$inline_script = "
(function () {
var SITE_KEY = '{$site_key_js}';
var SELECTOR = 'form#loginform, form#registerform, form#lostpasswordform,' +
'form.woocommerce-form-login, form.woocommerce-form-register';
function forms() {
return document.querySelectorAll(SELECTOR);
}
function setToken(token) {
forms().forEach(function (form) {
var input = form.querySelector('input[name=\"g-recaptcha-response\"]');
if (!input) {
input = document.createElement('input');
input.type = 'hidden';
input.name = 'g-recaptcha-response';
form.appendChild(input);
}
input.value = token;
});
}
function refresh() {
if (typeof grecaptcha === 'undefined' || !forms().length) { return; }
grecaptcha.ready(function () {
grecaptcha.execute(SITE_KEY, { action: 'login_register' }).then(setToken);
});
}
document.addEventListener('DOMContentLoaded', function () {
refresh();
// Tokens last ~120s; stay ahead of that.
setInterval(refresh, 90000);
forms().forEach(function (form) {
form.addEventListener('submit', function () { refresh(); });
});
});
})();
";
wp_add_inline_script( 'google-recaptcha-v3', $inline_script );
} else {
wp_enqueue_script(
'google-recaptcha-v2',
'https://www.google.com/recaptcha/api.js',
array(),
null,
true
);
// Loaded on the front end too, so the widget is styled inside
// WooCommerce and theme login forms rather than only on wp-login.
$this->enqueue_style(
'google-recaptcha-v2',
TPSA_ASSETS_URL . '/login/css/recaptcha.css'
);
}
}
/**
* Display reCAPTCHA box in form.
*/
public function show_recaptcha() {
$version = isset( $this->settings['version'] ) ? sanitize_key( $this->settings['version'] ) : 'v2';
// v3 is invisible: it has no widget to place in the form.
if ( 'v2' !== $version ) {
return;
}
$site_key = isset( $this->settings['site-key'] ) ? sanitize_text_field( (string) $this->settings['site-key'] ) : '';
$theme = isset( $this->settings['theme'] ) ? sanitize_key( (string) $this->settings['theme'] ) : 'light';
$theme = in_array( $theme, ['light', 'dark'], true ) ? $theme : 'light';
// The wrapper is what the stylesheet targets, so the widget can be
// scaled to the form width instead of the login box being widened.
echo '
';
}
/**
* Show error if keys are missing.
*/
public function show_recaptcha_error() {
echo '';
esc_html_e( 'reCAPTCHA is enabled but its keys are missing, so it cannot be shown. Please contact the site administrator.', 'admin-safety-guard' );
echo '
';
}
/**
* Verify reCAPTCHA on WP login.
*
* @param \WP_User|\WP_Error $user
* @param string $username
* @param string $password
* @return \WP_User|\WP_Error
*/
public function verify_recaptcha_on_login( $user, $username, $password ) {
// Avoid undefined index warning.
$method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
if ( 'POST' !== $method ) {
return $user;
}
// Something earlier in the chain already rejected this attempt (bad
// credentials, or a locked-out address). Verifying the token would be
// an outbound HTTP request to Google for a sign-in that cannot succeed
// — exactly the request an attacker would love to make us repeat.
if ( is_wp_error( $user ) ) {
return $user;
}
// The two-factor code screen re-posts to the login form without a
// captcha widget; it is handled before `authenticate` and needs no
// second verification.
if ( isset( $_POST['tpsa_2fa_token'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
return $user;
}
// NOTE: WordPress login form does not include a nonce that we can rely on here.
// reCAPTCHA token verification + WP core login nonce protections are sufficient.
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Login form POST is verified by reCAPTCHA token and WP core auth flow.
$token = isset( $_POST['g-recaptcha-response'] )
? sanitize_text_field( wp_unslash( $_POST['g-recaptcha-response'] ) )
: '';
if ( empty( $token ) ) {
return new WP_Error( 'recaptcha_missing', __( 'reCAPTCHA verification missing.', 'admin-safety-guard' ) );
}
$response = $this->verify_recaptcha( $token );
if ( is_wp_error( $response ) ) {
return $response;
}
return $user;
}
/**
* Verify reCAPTCHA on WP registration.
*
* @param \WP_Error $errors
* @return \WP_Error
*/
public function verify_recaptcha_on_register( $errors ) {
$method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
if ( 'POST' !== $method ) {
return $errors;
}
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Registration form nonce is not always available; reCAPTCHA token is verified.
$token = isset( $_POST['g-recaptcha-response'] )
? sanitize_text_field( wp_unslash( $_POST['g-recaptcha-response'] ) )
: '';
$result = $this->verify_recaptcha( $token );
if ( is_wp_error( $result ) ) {
$errors->add( 'recaptcha_error', esc_html( $result->get_error_message() ) );
}
return $errors;
}
/**
* WooCommerce: Verify reCAPTCHA on login.
*
* @param \WP_Error $errors
* @param string $username
* @param string $password
*
* @return \WP_Error
*/
public function wc_verify_recaptcha_on_login( $errors, $username, $password ) {
$method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
if ( 'POST' !== $method ) {
return $errors;
}
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Woo login form nonce is not consistently available; reCAPTCHA token is verified.
$token = isset( $_POST['g-recaptcha-response'] )
? sanitize_text_field( wp_unslash( $_POST['g-recaptcha-response'] ) )
: '';
$result = $this->verify_recaptcha( $token );
if ( is_wp_error( $result ) ) {
$errors->add( 'recaptcha_error', esc_html( $result->get_error_message() ) );
}
return $errors;
}
/**
* WooCommerce: Verify reCAPTCHA on registration.
*
* @param \WP_Error $errors
* @param string $username
* @param string $password
* @param string $email
*
* @return \WP_Error
*/
public function wc_verify_recaptcha_on_register( $errors, $username, $password, $email ) {
$method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
if ( 'POST' !== $method ) {
return $errors;
}
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Woo registration nonce is not consistently available; reCAPTCHA token is verified.
$token = isset( $_POST['g-recaptcha-response'] )
? sanitize_text_field( wp_unslash( $_POST['g-recaptcha-response'] ) )
: '';
$result = $this->verify_recaptcha( $token );
if ( is_wp_error( $result ) ) {
$errors->add( 'recaptcha_error', esc_html( $result->get_error_message() ) );
}
return $errors;
}
/**
* reCAPTCHA token verification.
*
* @param string $token
* @return true|\WP_Error
*/
private function verify_recaptcha( $token ) {
$secret_key = isset( $this->settings['secret-key'] ) ? sanitize_text_field( (string) $this->settings['secret-key'] ) : '';
$version = isset( $this->settings['version'] ) ? sanitize_key( (string) $this->settings['version'] ) : 'v2';
$token = is_scalar( $token ) ? sanitize_text_field( (string) $token ) : '';
if ( empty( $secret_key ) ) {
return new WP_Error( 'recaptcha_missing_secret', __( 'reCAPTCHA secret key is missing.', 'admin-safety-guard' ) );
}
if ( empty( $token ) ) {
return new WP_Error( 'recaptcha_missing', __( 'reCAPTCHA token is missing.', 'admin-safety-guard' ) );
}
$remote_ip = isset( $_SERVER['REMOTE_ADDR'] )
? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) )
: '';
$response = wp_remote_post(
'https://www.google.com/recaptcha/api/siteverify',
array(
'timeout' => 20,
'body' => array(
'secret' => $secret_key,
'response' => $token,
'remoteip' => $remote_ip,
),
)
);
if ( is_wp_error( $response ) ) {
// Avoid error_log() in production to pass WP coding standards.
// If you need debugging, use WP_DEBUG + a dedicated logger, or trigger an action for developers.
return new WP_Error( 'recaptcha_failed', __( 'Could not contact reCAPTCHA server.', 'admin-safety-guard' ) );
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( empty( $body['success'] ) ) {
\ThemePaste\SecureAdmin\Classes\ThreatLog::report( 'recaptcha_fail' );
return new WP_Error( 'recaptcha_invalid', __( 'reCAPTCHA verification failed.', 'admin-safety-guard' ) );
}
if ( 'v3' === $version ) {
$score = isset( $body['score'] ) ? (float) $body['score'] : 0.0;
$action = isset( $body['action'] ) ? sanitize_text_field( (string) $body['action'] ) : '';
if ( $score < 0.5 ) {
return new WP_Error( 'recaptcha_low_score', __( 'reCAPTCHA score too low. Try again.', 'admin-safety-guard' ) );
}
if ( 'login_register' !== $action ) {
return new WP_Error( 'recaptcha_action_mismatch', __( 'reCAPTCHA action mismatch.', 'admin-safety-guard' ) );
}
}
return true;
}
/**
* Get plugin settings.
*
* @return array
*/
private function get_settings() {
$option_name = get_tpsa_settings_option_name( $this->features_id );
$settings = get_option( $option_name, array() );
return is_array( $settings ) ? $settings : array();
}
/**
* Check if the feature is enabled.
*
* @param array $settings
* @return bool
*/
private function is_enabled( $settings ) {
return !empty( $settings['enable'] ) && (int) $settings['enable'] === 1;
}
}