Files
WP-Security-Pack/wp-security-pack/includes/class-wpsp-login-protection.php
T
2026-01-25 20:21:17 +01:00

1178 lines
33 KiB
PHP

<?php
/**
* Login protection for WP Security Pack.
*
* @package WP_Security_Pack
*/
// Prevent direct access.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Login protection class.
*/
class WPSP_Login_Protection {
/**
* Custom login URL slug.
*
* @var string|null
*/
private $custom_login_slug = null;
/**
* Cookie name for custom login access.
*
* @var string
*/
private $cookie_name = 'wpsp_login_access';
/**
* Constructor.
*/
public function __construct() {
// Failed login tracking.
add_action( 'wp_login_failed', array( $this, 'handle_failed_login' ) );
// Priority 10 runs BEFORE WordPress's authenticate (priority 20) to block locked-out IPs early.
add_filter( 'authenticate', array( $this, 'check_lockout' ), 10, 3 );
// Successful login tracking.
add_action( 'wp_login', array( $this, 'handle_successful_login' ), 10, 2 );
// Block locked out IPs from seeing the login form at all.
// This runs on login_init which fires before the login form is displayed.
add_action( 'login_init', array( $this, 'block_locked_out_on_login_page' ), 1 );
// Custom login URL - must run very early before WordPress processes the request.
if ( WP_Security_Pack::get_setting( 'login_rename_enabled', false ) ) {
$this->custom_login_slug = WP_Security_Pack::get_setting( 'login_custom_url', '' );
if ( ! empty( $this->custom_login_slug ) ) {
// Handle custom login slug immediately (constructor runs during plugins_loaded).
$this->handle_custom_login_slug_early();
// These hooks can run on init.
add_action( 'init', array( $this, 'restrict_wp_login' ), 1 );
add_filter( 'site_url', array( $this, 'filter_login_url' ), 10, 4 );
add_filter( 'wp_redirect', array( $this, 'filter_redirect_url' ), 10, 2 );
add_filter( 'login_url', array( $this, 'filter_login_url_direct' ), 10, 3 );
}
}
// Hide wp-admin for non-logged-in users.
if ( WP_Security_Pack::get_setting( 'hide_wp_admin', false ) ) {
// Must run early - WordPress redirects wp-admin to wp-login.php before init.
$this->block_wp_admin_early();
add_action( 'init', array( $this, 'hide_wp_admin' ), 1 );
}
// Honeypot field.
if ( WP_Security_Pack::get_setting( 'honeypot_enabled', true ) ) {
add_action( 'login_form', array( $this, 'add_honeypot_field' ) );
add_action( 'register_form', array( $this, 'add_honeypot_field' ) );
add_filter( 'authenticate', array( $this, 'check_honeypot' ), 1, 3 );
add_filter( 'registration_errors', array( $this, 'check_honeypot_registration' ), 10, 3 );
}
// Hide login error messages (prevents username enumeration).
if ( WP_Security_Pack::get_setting( 'hide_login_errors', true ) ) {
add_filter( 'login_errors', array( $this, 'hide_login_errors' ) );
}
}
/**
* Block locked out IPs from seeing the login page.
* Runs on login_init before the form is displayed.
*/
public function block_locked_out_on_login_page() {
// Check if custom login URL is enabled and block direct wp-login.php access.
if ( $this->should_block_direct_wp_login() ) {
$this->redirect_to_404();
}
// Check lockout first.
if ( $this->is_ip_locked_out_early() ) {
$this->show_lockout_message_early();
}
// Check admin access restriction.
if ( $this->is_admin_access_restricted() ) {
$this->show_admin_access_denied_message();
}
}
/**
* Check if direct wp-login.php access should be blocked.
*
* @return bool True if access should be blocked.
*/
private function should_block_direct_wp_login() {
// Only applies when custom login URL is enabled.
if ( ! WP_Security_Pack::get_setting( 'login_rename_enabled', false ) ) {
return false;
}
$custom_slug = WP_Security_Pack::get_setting( 'login_custom_url', '' );
if ( empty( $custom_slug ) ) {
return false;
}
// Allow logged-in users (for logout, profile actions, etc.).
$logged_in = false;
foreach ( $_COOKIE as $name => $value ) {
if ( strpos( $name, 'wordpress_logged_in_' ) === 0 ) {
$logged_in = true;
break;
}
}
if ( $logged_in ) {
return false;
}
// Allow if user has the access cookie (visited custom login URL before).
// This allows all WordPress flows (password reset, etc.) to work normally.
$expected_value = wp_hash( 'wpsp_login_' . $custom_slug );
$cookie_value = isset( $_COOKIE['wpsp_login_access'] ) ? $_COOKIE['wpsp_login_access'] : '';
if ( $cookie_value === $expected_value ) {
return false;
}
// No valid cookie - block direct wp-login.php access.
return true;
}
/**
* Check if admin access is restricted for current IP/country.
*
* @return bool True if access should be denied.
*/
private function is_admin_access_restricted() {
if ( ! WP_Security_Pack::get_setting( 'admin_access_restriction', false ) ) {
return false;
}
$ip = WPSP_Helper::get_client_ip();
if ( ! $ip ) {
return false;
}
// Check if IP is whitelisted (always allow).
$ip_control = wpsp()->get_component( 'ip_control' );
if ( ! $ip_control ) {
$ip_control = new WPSP_IP_Control();
}
if ( $ip_control->is_whitelisted( $ip ) ) {
return false;
}
$allowed_countries = WP_Security_Pack::get_setting( 'admin_allowed_countries', array() );
$allowed_ips = WP_Security_Pack::get_setting( 'admin_allowed_ips', '' );
$allowed_ip_list = WPSP_Helper::parse_ip_list( $allowed_ips );
$has_country_restriction = ! empty( $allowed_countries );
$has_ip_restriction = ! empty( $allowed_ip_list );
// If no restrictions configured, allow access.
if ( ! $has_country_restriction && ! $has_ip_restriction ) {
return false;
}
$access_granted = false;
// Check IP restriction.
if ( $has_ip_restriction && WPSP_Helper::ip_matches_rules( $ip, $allowed_ip_list ) ) {
$access_granted = true;
}
// Check country restriction.
if ( ! $access_granted && $has_country_restriction ) {
$geo_blocking = wpsp()->get_component( 'geo_blocking' );
if ( ! $geo_blocking ) {
$geo_blocking = new WPSP_Geo_Blocking();
}
$country_code = $geo_blocking->get_country_code( $ip );
if ( $country_code && in_array( $country_code, $allowed_countries, true ) ) {
$access_granted = true;
}
}
// If access not granted, it should be restricted.
return ! $access_granted;
}
/**
* Show admin access denied message and exit.
*/
private function show_admin_access_denied_message() {
$ip = WPSP_Helper::get_client_ip();
// Log the blocked access attempt.
WPSP_Activity_Log::log(
WPSP_Activity_Log::EVENT_IP_BLOCKED,
$ip,
null,
__( 'Admin access denied: country/IP not allowed', 'wp-security-pack' )
);
status_header( 403 );
nocache_headers();
wp_die(
esc_html__( 'Admin access is not permitted from your location.', 'wp-security-pack' ),
esc_html__( 'Access Denied', 'wp-security-pack' ),
array(
'response' => 403,
'back_link' => false,
)
);
}
/**
* Handle failed login attempt.
*
* @param string $username Username attempted.
*/
public function handle_failed_login( $username ) {
if ( ! WP_Security_Pack::get_setting( 'login_limit_enabled', true ) ) {
return;
}
$ip = WPSP_Helper::get_client_ip();
if ( ! $ip ) {
return;
}
// Check if IP is whitelisted.
$ip_control = wpsp()->get_component( 'ip_control' );
if ( $ip_control && $ip_control->is_whitelisted( $ip ) ) {
WPSP_Activity_Log::log( WPSP_Activity_Log::EVENT_LOGIN_FAILED, $ip, $username, __( 'Failed login (whitelisted IP)', 'wp-security-pack' ) );
return;
}
// Log the failed attempt.
WPSP_Activity_Log::log( WPSP_Activity_Log::EVENT_LOGIN_FAILED, $ip, $username );
// Increment failed attempts counter.
$attempts = $this->increment_failed_attempts( $ip );
// Check if lockout threshold reached.
$max_attempts = WP_Security_Pack::get_setting( 'login_max_attempts', 5 );
if ( $attempts >= $max_attempts ) {
$this->lockout_ip( $ip );
}
// Send email alert if enabled.
$this->maybe_send_alert( 'failed_login', $ip, $username, $attempts );
}
/**
* Handle successful login.
*
* @param string $user_login Username.
* @param WP_User $user User object.
*/
public function handle_successful_login( $user_login, $user ) {
$ip = WPSP_Helper::get_client_ip();
// Log the successful login.
WPSP_Activity_Log::log( WPSP_Activity_Log::EVENT_LOGIN_SUCCESS, $ip, $user_login );
// Clear failed attempts for this IP.
$this->clear_failed_attempts( $ip );
// Send admin login notification if enabled.
if ( WP_Security_Pack::get_setting( 'admin_login_notify', false ) && user_can( $user, 'manage_options' ) ) {
$this->send_admin_login_notification( $user, $ip );
}
}
/**
* Send notification when an admin logs in.
*
* @param WP_User $user User object.
* @param string $ip IP address.
*/
private function send_admin_login_notification( $user, $ip ) {
$email = WP_Security_Pack::get_setting( 'email_alerts_address', get_option( 'admin_email' ) );
if ( empty( $email ) ) {
return;
}
// Check if this is a new IP for this user.
$known_ips = get_user_meta( $user->ID, '_wpsp_known_ips', true );
if ( ! is_array( $known_ips ) ) {
$known_ips = array();
}
// Only notify for new IPs (or always if no IPs recorded yet).
$is_new_ip = ! in_array( $ip, $known_ips, true );
// Add IP to known list (keep last 10).
if ( $is_new_ip ) {
$known_ips[] = $ip;
$known_ips = array_slice( $known_ips, -10 );
update_user_meta( $user->ID, '_wpsp_known_ips', $known_ips );
}
// Only send notification for new IPs.
if ( ! $is_new_ip ) {
return;
}
$site_name = get_bloginfo( 'name' );
$subject = sprintf(
/* translators: %s: Site name */
__( '[%s] Admin Login from New IP', 'wp-security-pack' ),
$site_name
);
$message = sprintf(
/* translators: 1: Username, 2: Site name */
__( 'An administrator account logged in to %2$s from a new IP address.', 'wp-security-pack' ),
$user->user_login,
$site_name
) . "\n\n";
$message .= sprintf( __( 'Username: %s', 'wp-security-pack' ), $user->user_login ) . "\n";
$message .= sprintf( __( 'IP Address: %s', 'wp-security-pack' ), $ip ) . "\n";
$message .= sprintf( __( 'Time: %s', 'wp-security-pack' ), current_time( 'mysql' ) ) . "\n\n";
$message .= __( 'If this was not you, please secure your account immediately.', 'wp-security-pack' ) . "\n";
wp_mail( $email, $subject, $message );
}
/**
* Hide specific login error messages to prevent username enumeration.
*
* @param string $error Error message.
* @return string
*/
public function hide_login_errors( $error ) {
// Return generic message instead of revealing if username or password was wrong.
return __( 'Invalid username or password.', 'wp-security-pack' );
}
/**
* Check if IP is locked out before authentication.
*
* @param WP_User|WP_Error|null $user User object or error.
* @param string $username Username.
* @param string $password Password.
* @return WP_User|WP_Error|null
*/
public function check_lockout( $user, $username, $password ) {
if ( ! WP_Security_Pack::get_setting( 'login_limit_enabled', true ) ) {
return $user;
}
if ( empty( $username ) ) {
return $user;
}
$ip = WPSP_Helper::get_client_ip();
if ( ! $ip ) {
return $user;
}
// Check if IP is whitelisted.
$ip_control = wpsp()->get_component( 'ip_control' );
if ( $ip_control && $ip_control->is_whitelisted( $ip ) ) {
return $user;
}
// Check if IP is locked out.
$lockout = $this->get_lockout( $ip );
$max_attempts = (int) WP_Security_Pack::get_setting( 'login_max_attempts', 5 );
$lockout_duration = (int) WP_Security_Pack::get_setting( 'login_lockout_duration', 15 );
if ( $lockout && (int) $lockout->failed_attempts >= $max_attempts ) {
// Calculate lockout expiry from updated_at + duration.
// Use current_time('timestamp') for WordPress timezone consistency.
$updated_time = strtotime( $lockout->updated_at );
$lockout_expires = $updated_time + ( $lockout_duration * 60 );
$current_time = current_time( 'timestamp' );
if ( $current_time < $lockout_expires ) {
// Still locked out.
$remaining = human_time_diff( $current_time, $lockout_expires );
return new WP_Error(
'wpsp_locked_out',
sprintf(
/* translators: %s: Time remaining */
__( 'Too many failed login attempts. Please try again in %s.', 'wp-security-pack' ),
$remaining
)
);
} else {
// Lockout expired - clear the record so they can try again.
$this->clear_failed_attempts( $ip );
}
}
return $user;
}
/**
* Increment failed attempts counter.
*
* @param string $ip IP address.
* @return int New count.
*/
private function increment_failed_attempts( $ip ) {
global $wpdb;
$table = WPSP_DB::get_lockout_table();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$existing = $wpdb->get_row(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT * FROM {$table} WHERE ip_address = %s",
$ip
)
);
if ( $existing ) {
$new_count = $existing->failed_attempts + 1;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update(
$table,
array(
'failed_attempts' => $new_count,
'updated_at' => current_time( 'mysql' ),
),
array( 'ip_address' => $ip ),
array( '%d', '%s' ),
array( '%s' )
);
return $new_count;
}
// Create new record.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert(
$table,
array(
'ip_address' => $ip,
'failed_attempts' => 1,
'created_at' => current_time( 'mysql' ),
),
array( '%s', '%d', '%s' )
);
return 1;
}
/**
* Clear failed attempts for an IP.
*
* @param string $ip IP address.
*/
private function clear_failed_attempts( $ip ) {
global $wpdb;
$table = WPSP_DB::get_lockout_table();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->delete(
$table,
array( 'ip_address' => $ip ),
array( '%s' )
);
}
/**
* Lockout an IP address.
*
* @param string $ip IP address.
*/
private function lockout_ip( $ip ) {
global $wpdb;
$table = WPSP_DB::get_lockout_table();
$duration = (int) WP_Security_Pack::get_setting( 'login_lockout_duration', 15 );
// Store Unix timestamp to avoid timezone issues.
$lockout_until = time() + ( $duration * 60 );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->update(
$table,
array(
'lockout_until' => $lockout_until,
'updated_at' => current_time( 'mysql' ),
),
array( 'ip_address' => $ip ),
array( '%d', '%s' ),
array( '%s' )
);
// Log the lockout.
WPSP_Activity_Log::log(
WPSP_Activity_Log::EVENT_LOCKOUT,
$ip,
null,
sprintf(
/* translators: %d: Duration in minutes */
__( 'Locked out for %d minutes', 'wp-security-pack' ),
$duration
)
);
// Send email alert.
$this->maybe_send_alert( 'lockout', $ip );
// Check for auto-blacklist of repeat offenders.
$this->maybe_auto_blacklist( $ip );
}
/**
* Check if IP should be auto-blacklisted for repeat lockouts.
*
* @param string $ip IP address.
*/
private function maybe_auto_blacklist( $ip ) {
if ( ! WP_Security_Pack::get_setting( 'auto_blacklist_enabled', false ) ) {
return;
}
$threshold = (int) WP_Security_Pack::get_setting( 'auto_blacklist_threshold', 3 );
if ( $threshold < 1 ) {
return;
}
// Count lockouts for this IP in the activity log.
$lockout_count = $this->count_ip_lockouts( $ip );
if ( $lockout_count >= $threshold ) {
$this->add_ip_to_blacklist( $ip );
// Log the auto-blacklist.
WPSP_Activity_Log::log(
WPSP_Activity_Log::EVENT_IP_BLOCKED,
$ip,
null,
sprintf(
/* translators: %d: Number of lockouts */
__( 'Auto-blacklisted after %d lockouts', 'wp-security-pack' ),
$lockout_count
)
);
// Send email alert.
$this->maybe_send_alert( 'auto_blacklist', $ip, $lockout_count );
}
}
/**
* Count the number of lockouts for an IP address.
*
* @param string $ip IP address.
* @return int
*/
private function count_ip_lockouts( $ip ) {
global $wpdb;
$table = WPSP_DB::get_log_table();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$count = $wpdb->get_var(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT COUNT(*) FROM {$table} WHERE ip_address = %s AND event_type = %s",
$ip,
WPSP_Activity_Log::EVENT_LOCKOUT
)
);
return (int) $count;
}
/**
* Add an IP address to the blacklist.
*
* @param string $ip IP address.
*/
private function add_ip_to_blacklist( $ip ) {
$current_blacklist = WP_Security_Pack::get_setting( 'ip_blacklist', '' );
// Check if IP is already in the blacklist.
$blacklist_array = array_filter( array_map( 'trim', explode( "\n", $current_blacklist ) ) );
if ( in_array( $ip, $blacklist_array, true ) ) {
return;
}
// Add IP to blacklist.
$blacklist_array[] = $ip;
$new_blacklist = implode( "\n", $blacklist_array );
WP_Security_Pack::update_setting( 'ip_blacklist', $new_blacklist );
// Clear the IP control cache.
$ip_control = wpsp()->get_component( 'ip_control' );
if ( $ip_control ) {
$ip_control->clear_cache();
}
}
/**
* Get lockout record for an IP.
*
* @param string $ip IP address.
* @return object|null
*/
private function get_lockout( $ip ) {
global $wpdb;
$table = WPSP_DB::get_lockout_table();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
return $wpdb->get_row(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT * FROM {$table} WHERE ip_address = %s",
$ip
)
);
}
/**
* Block wp-admin access early - called from constructor.
* Runs before WordPress can redirect to wp-login.php.
*/
private function block_wp_admin_early() {
// Check if user is logged in - we can't use is_user_logged_in() this early.
// Check for the logged_in cookie instead.
$logged_in = false;
foreach ( $_COOKIE as $name => $value ) {
if ( strpos( $name, 'wordpress_logged_in_' ) === 0 ) {
$logged_in = true;
break;
}
}
if ( $logged_in ) {
return;
}
// Check if requesting wp-admin.
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : '';
$is_wp_admin = strpos( $request_uri, '/wp-admin' ) !== false;
$is_ajax = strpos( $request_uri, 'admin-ajax.php' ) !== false;
if ( $is_wp_admin && ! $is_ajax ) {
// Redirect to a non-existent URL to trigger WordPress's themed 404 page.
$home = get_option( 'home', '' );
$fake_url = rtrim( $home, '/' ) . '/wpsp-404-' . mt_rand( 1000, 9999 );
header( 'Location: ' . $fake_url, true, 302 );
exit;
}
}
/**
* Handle access to the custom login slug - called early from constructor.
* Directly loads wp-login.php when accessed via custom slug.
*/
public function handle_custom_login_slug_early() {
if ( ! $this->is_custom_login_slug_request_early() ) {
return;
}
// Check if IP is locked out before showing login form.
if ( $this->is_ip_locked_out_early() ) {
$this->show_lockout_message_early();
}
// Check admin access restriction before showing login form.
if ( $this->is_admin_access_restricted() ) {
$this->show_admin_access_denied_message();
}
// Set a flag that user accessed via custom login slug.
// This will be checked by restrict_wp_login on subsequent requests.
$cookie_value = wp_hash( 'wpsp_login_' . $this->custom_login_slug );
// Define cookie constants if not already defined.
if ( ! defined( 'COOKIEPATH' ) ) {
define( 'COOKIEPATH', '/' );
}
if ( ! defined( 'COOKIE_DOMAIN' ) ) {
define( 'COOKIE_DOMAIN', '' );
}
setcookie(
$this->cookie_name,
$cookie_value,
time() + DAY_IN_SECONDS,
COOKIEPATH,
COOKIE_DOMAIN,
is_ssl(),
true
);
// Also set it in the superglobal for immediate availability.
$_COOKIE[ $this->cookie_name ] = $cookie_value;
// Directly load the login page (like WPS Hide Login does).
require_once ABSPATH . 'wp-login.php';
exit;
}
/**
* Check if IP is locked out - early version that works before init.
*
* @return bool
*/
private function is_ip_locked_out_early() {
if ( ! WP_Security_Pack::get_setting( 'login_limit_enabled', true ) ) {
return false;
}
$ip = WPSP_Helper::get_client_ip();
if ( ! $ip ) {
return false;
}
// Check whitelist first.
$ip_control = wpsp()->get_component( 'ip_control' );
if ( $ip_control && $ip_control->is_whitelisted( $ip ) ) {
return false;
}
// Check lockout status directly from database.
global $wpdb;
$table = WPSP_DB::get_lockout_table();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$lockout = $wpdb->get_row(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT * FROM {$table} WHERE ip_address = %s",
$ip
)
);
if ( ! $lockout ) {
return false;
}
$max_attempts = (int) WP_Security_Pack::get_setting( 'login_max_attempts', 5 );
$lockout_minutes = (int) WP_Security_Pack::get_setting( 'login_lockout_duration', 15 );
$current_time = time();
// Check if IP has reached max attempts and is still within lockout window.
if ( (int) $lockout->failed_attempts >= $max_attempts && ! empty( $lockout->updated_at ) ) {
$updated_time = strtotime( $lockout->updated_at );
$lockout_expires = $updated_time + ( $lockout_minutes * 60 );
if ( $current_time < $lockout_expires ) {
return true;
}
}
// Also check lockout_until for honeypot/manual blocks.
if ( ! empty( $lockout->lockout_until ) && is_numeric( $lockout->lockout_until ) ) {
if ( $current_time < (int) $lockout->lockout_until ) {
return true;
}
}
return false;
}
/**
* Show lockout message and exit - early version.
*/
private function show_lockout_message_early() {
$lockout_minutes = (int) WP_Security_Pack::get_setting( 'login_lockout_duration', 15 );
status_header( 403 );
nocache_headers();
wp_die(
sprintf(
/* translators: %d: Lockout duration in minutes */
esc_html__( 'Too many failed login attempts. Please try again in %d minutes.', 'wp-security-pack' ),
$lockout_minutes
),
esc_html__( 'Access Denied', 'wp-security-pack' ),
array(
'response' => 403,
'back_link' => false,
)
);
}
/**
* Check if the current request is for the custom login slug - early version.
* Uses native PHP functions to avoid dependency on WordPress functions.
*
* @return bool
*/
private function is_custom_login_slug_request_early() {
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : '';
$request_path = trim( parse_url( $request_uri, PHP_URL_PATH ), '/' );
// Get the site path for subdirectory installations.
// Use get_option directly since home_url() might have filters.
$home = get_option( 'home', '' );
$home_path = trim( parse_url( $home, PHP_URL_PATH ), '/' );
// Remove the home path prefix from the request path.
if ( ! empty( $home_path ) && strpos( $request_path, $home_path ) === 0 ) {
$request_path = trim( substr( $request_path, strlen( $home_path ) ), '/' );
}
return $request_path === $this->custom_login_slug;
}
/**
* Restrict direct access to wp-login.php.
* Block unless user has the access cookie from custom login slug.
*/
public function restrict_wp_login() {
// Check if we're on wp-login.php using the reliable $pagenow global.
if ( ! isset( $GLOBALS['pagenow'] ) || 'wp-login.php' !== $GLOBALS['pagenow'] ) {
return;
}
// Allow logged-in users.
if ( is_user_logged_in() ) {
return;
}
// Allow if user has the access cookie (came through custom login slug before).
$expected_value = wp_hash( 'wpsp_login_' . $this->custom_login_slug );
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$cookie_value = isset( $_COOKIE[ $this->cookie_name ] ) ? $_COOKIE[ $this->cookie_name ] : '';
if ( $cookie_value === $expected_value ) {
return; // Cookie is valid, allow access.
}
// No valid cookie - block access with 404.
$this->redirect_to_404();
}
/**
* Check if the current request is for the custom login slug.
*
* @return bool
*/
private function is_custom_login_slug_request() {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
$request_path = trim( wp_parse_url( $request_uri, PHP_URL_PATH ), '/' );
// Get the site path for subdirectory installations.
$home_path = trim( wp_parse_url( home_url(), PHP_URL_PATH ), '/' );
// Remove the home path prefix from the request path.
if ( ! empty( $home_path ) && strpos( $request_path, $home_path ) === 0 ) {
$request_path = trim( substr( $request_path, strlen( $home_path ) ), '/' );
}
return $request_path === $this->custom_login_slug;
}
/**
* Redirect to 404 page.
*/
private function redirect_to_404() {
// Try to get the site's 404 page URL.
$home_url = home_url( '/' );
// Use a non-existent URL to trigger WordPress 404.
$fake_404_url = home_url( '/wpsp-not-found-' . wp_rand( 1000, 9999 ) . '/' );
status_header( 404 );
nocache_headers();
wp_safe_redirect( $fake_404_url, 302 );
exit;
}
/**
* Hide wp-admin for non-logged-in users.
*/
public function hide_wp_admin() {
if ( is_user_logged_in() ) {
return;
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
// Check if trying to access wp-admin (works for both root and subdirectory installs).
// Allow admin-ajax.php for frontend AJAX functionality.
$is_wp_admin = strpos( $request_uri, '/wp-admin' ) !== false;
$is_ajax = strpos( $request_uri, 'admin-ajax.php' ) !== false;
if ( $is_wp_admin && ! $is_ajax ) {
$this->redirect_to_404();
}
}
/**
* Filter login URL in site_url.
*
* @param string $url The URL.
* @param string $path The path.
* @param string|null $scheme The scheme.
* @param int|null $blog_id Blog ID.
* @return string
*/
public function filter_login_url( $url, $path, $scheme, $blog_id ) {
if ( strpos( $url, 'wp-login.php' ) !== false && ! empty( $this->custom_login_slug ) ) {
$url = str_replace( 'wp-login.php', $this->custom_login_slug, $url );
}
return $url;
}
/**
* Filter login URL directly.
*
* @param string $login_url The login URL.
* @param string $redirect The redirect URL.
* @param bool $force_reauth Force reauth.
* @return string
*/
public function filter_login_url_direct( $login_url, $redirect, $force_reauth ) {
if ( ! empty( $this->custom_login_slug ) ) {
$login_url = str_replace( 'wp-login.php', $this->custom_login_slug, $login_url );
}
return $login_url;
}
/**
* Filter redirect URLs.
*
* @param string $location Redirect location.
* @param int $status HTTP status.
* @return string
*/
public function filter_redirect_url( $location, $status ) {
if ( strpos( $location, 'wp-login.php' ) !== false && ! empty( $this->custom_login_slug ) ) {
// Check the redirect_to parameter in the location URL.
$redirect_to = '';
if ( preg_match( '/redirect_to=([^&]+)/', $location, $matches ) ) {
$redirect_to = urldecode( $matches[1] );
}
// If redirect_to contains wp-admin and hide_wp_admin is enabled, block with 404.
// This prevents /wp-admin from revealing the custom login URL.
// Use get_option directly to avoid any potential issues with class method.
$settings = get_option( 'wpsp_settings', array() );
$hide_wp_admin = ! empty( $settings['hide_wp_admin'] );
$is_wp_admin_redirect = strpos( $redirect_to, 'wp-admin' ) !== false && strpos( $redirect_to, 'admin-ajax.php' ) === false;
if ( $hide_wp_admin && $is_wp_admin_redirect ) {
// Block with 404 - don't reveal the custom login URL.
status_header( 404 );
nocache_headers();
// Exit immediately to prevent the redirect.
exit;
}
$location = str_replace( 'wp-login.php', $this->custom_login_slug, $location );
}
return $location;
}
/**
* Show 404 page.
*/
private function show_404() {
global $wp_query;
status_header( 404 );
nocache_headers();
if ( $wp_query ) {
$wp_query->set_404();
}
// Try to load theme 404 template.
$template = get_404_template();
if ( $template ) {
include $template;
} else {
wp_die(
esc_html__( 'Page not found.', 'wp-security-pack' ),
esc_html__( '404 Not Found', 'wp-security-pack' ),
array( 'response' => 404 )
);
}
exit;
}
/**
* Add honeypot field to login form.
*/
public function add_honeypot_field() {
// Honeypot field - hidden via CSS, bots will fill it.
?>
<p class="wpsp-hp-field" style="position:absolute;left:-9999px;top:-9999px;">
<label for="wpsp_hp_email"><?php esc_html_e( 'Leave this field empty', 'wp-security-pack' ); ?></label>
<input type="text" name="wpsp_hp_email" id="wpsp_hp_email" value="" tabindex="-1" autocomplete="off" />
</p>
<?php
}
/**
* Check honeypot field during authentication.
*
* @param WP_User|WP_Error|null $user User object or error.
* @param string $username Username.
* @param string $password Password.
* @return WP_User|WP_Error|null
*/
public function check_honeypot( $user, $username, $password ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing
if ( ! empty( $_POST['wpsp_hp_email'] ) ) {
$ip = WPSP_Helper::get_client_ip();
WPSP_Activity_Log::log(
WPSP_Activity_Log::EVENT_LOGIN_FAILED,
$ip,
$username,
__( 'Honeypot triggered', 'wp-security-pack' )
);
// Auto-block this IP using configurable duration.
$ip_control = wpsp()->get_component( 'ip_control' );
$ban_duration = (int) WP_Security_Pack::get_setting( 'honeypot_ban_duration', 60 );
if ( $ip_control ) {
$ip_control->auto_block_ip( $ip, $ban_duration, __( 'Honeypot triggered', 'wp-security-pack' ) );
}
return new WP_Error(
'wpsp_honeypot',
__( 'Authentication failed.', 'wp-security-pack' )
);
}
return $user;
}
/**
* Check honeypot field during registration.
*
* @param WP_Error $errors Registration errors.
* @param string $sanitized_user_login User login.
* @param string $user_email User email.
* @return WP_Error
*/
public function check_honeypot_registration( $errors, $sanitized_user_login, $user_email ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing
if ( ! empty( $_POST['wpsp_hp_email'] ) ) {
$errors->add(
'wpsp_honeypot',
__( 'Registration failed.', 'wp-security-pack' )
);
// Auto-block this IP using configurable duration.
$ip = WPSP_Helper::get_client_ip();
$ip_control = wpsp()->get_component( 'ip_control' );
$ban_duration = (int) WP_Security_Pack::get_setting( 'honeypot_ban_duration', 60 );
if ( $ip_control && $ip ) {
$ip_control->auto_block_ip( $ip, $ban_duration, __( 'Honeypot triggered on registration', 'wp-security-pack' ) );
}
}
return $errors;
}
/**
* Maybe send email alert.
*
* @param string $type Alert type.
* @param string $ip IP address.
* @param string $username Username (optional).
* @param int $attempts Number of attempts (optional).
*/
private function maybe_send_alert( $type, $ip, $username = '', $attempts = 0 ) {
if ( ! WP_Security_Pack::get_setting( 'email_alerts_enabled', false ) ) {
return;
}
$email = WP_Security_Pack::get_setting( 'email_alerts_address', get_option( 'admin_email' ) );
if ( empty( $email ) ) {
return;
}
$site_name = get_bloginfo( 'name' );
$site_url = home_url();
switch ( $type ) {
case 'failed_login':
$threshold = WP_Security_Pack::get_setting( 'email_alert_threshold', 3 );
if ( $attempts < $threshold ) {
return;
}
$subject = sprintf(
/* translators: %s: Site name */
__( '[%s] Multiple Failed Login Attempts', 'wp-security-pack' ),
$site_name
);
$message = sprintf(
/* translators: 1: Number of attempts, 2: IP address, 3: Username, 4: Site URL */
__( "There have been %1\$d failed login attempts on your site.\n\nIP Address: %2\$s\nUsername attempted: %3\$s\nSite: %4\$s\n\nIf this wasn't you, the IP will be automatically locked out after reaching the threshold.", 'wp-security-pack' ),
$attempts,
$ip,
$username ? $username : __( '(empty)', 'wp-security-pack' ),
$site_url
);
break;
case 'lockout':
$subject = sprintf(
/* translators: %s: Site name */
__( '[%s] IP Address Locked Out', 'wp-security-pack' ),
$site_name
);
$message = sprintf(
/* translators: 1: IP address, 2: Site URL */
__( "An IP address has been locked out due to too many failed login attempts.\n\nIP Address: %1\$s\nSite: %2\$s", 'wp-security-pack' ),
$ip,
$site_url
);
break;
case 'auto_blacklist':
$subject = sprintf(
/* translators: %s: Site name */
__( '[%s] IP Permanently Blacklisted', 'wp-security-pack' ),
$site_name
);
$message = sprintf(
/* translators: 1: IP address, 2: Number of lockouts, 3: Site URL */
__( "An IP address has been permanently added to your blacklist due to repeated lockouts.\n\nIP Address: %1\$s\nTotal Lockouts: %2\$d\nSite: %3\$s\n\nThis IP will no longer be able to access your website.", 'wp-security-pack' ),
$ip,
$attempts,
$site_url
);
break;
default:
return;
}
wp_mail( $email, $subject, $message );
}
}