mirror of
https://gitlab.com/ArkHost/WP-Security-Pack.git
synced 2026-09-19 17:37:30 +02:00
1189 lines
35 KiB
PHP
1189 lines
35 KiB
PHP
<?php
|
|
/**
|
|
* Login protection for Security Pack.
|
|
*
|
|
* @package ArkHost_Security_Pack
|
|
*/
|
|
|
|
// Prevent direct access.
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Login protection class.
|
|
*/
|
|
class ARKSP_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 = 'arksp_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 );
|
|
|
|
// Honeypot field.
|
|
// NOTE: Must be registered BEFORE handle_custom_login_slug_early() which
|
|
// may load wp-login.php and exit, preventing any later hook registrations.
|
|
if ( ARKSP_Plugin::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).
|
|
// NOTE: Must be registered BEFORE handle_custom_login_slug_early() which
|
|
// may load wp-login.php and exit, preventing any later hook registrations.
|
|
if ( ARKSP_Plugin::get_setting( 'hide_login_errors', true ) ) {
|
|
// Replace specific auth errors at the source (runs after WP's authenticate at priority 20).
|
|
add_filter( 'authenticate', array( $this, 'genericize_auth_error' ), PHP_INT_MAX, 3 );
|
|
// Also filter the rendered error string as a safety net.
|
|
add_filter( 'login_errors', array( $this, 'hide_login_errors' ), PHP_INT_MAX );
|
|
}
|
|
|
|
// Custom login URL - must run very early before WordPress processes the request.
|
|
// WARNING: handle_custom_login_slug_early() may require wp-login.php and exit.
|
|
// All login-related hooks MUST be registered above this point.
|
|
if ( ARKSP_Plugin::get_setting( 'login_rename_enabled', false ) ) {
|
|
$this->custom_login_slug = ARKSP_Plugin::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 ( ARKSP_Plugin::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 );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 ( ! ARKSP_Plugin::get_setting( 'login_rename_enabled', false ) ) {
|
|
return false;
|
|
}
|
|
|
|
$custom_slug = ARKSP_Plugin::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( 'arksp_login_' . $custom_slug );
|
|
$cookie_value = isset( $_COOKIE['arksp_login_access'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['arksp_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 ( ! ARKSP_Plugin::get_setting( 'admin_access_restriction', false ) ) {
|
|
return false;
|
|
}
|
|
|
|
$ip = ARKSP_Helper::get_client_ip();
|
|
if ( ! $ip ) {
|
|
return false;
|
|
}
|
|
|
|
// Check if IP is whitelisted (always allow).
|
|
$ip_control = arksp()->get_component( 'ip_control' );
|
|
if ( ! $ip_control ) {
|
|
$ip_control = new ARKSP_IP_Control();
|
|
}
|
|
if ( $ip_control->is_whitelisted( $ip ) ) {
|
|
return false;
|
|
}
|
|
|
|
$allowed_countries = ARKSP_Plugin::get_setting( 'admin_allowed_countries', array() );
|
|
$allowed_ips = ARKSP_Plugin::get_setting( 'admin_allowed_ips', '' );
|
|
$allowed_ip_list = ARKSP_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 && ARKSP_Helper::ip_matches_rules( $ip, $allowed_ip_list ) ) {
|
|
$access_granted = true;
|
|
}
|
|
|
|
// Check country restriction.
|
|
if ( ! $access_granted && $has_country_restriction ) {
|
|
$geo_blocking = arksp()->get_component( 'geo_blocking' );
|
|
if ( ! $geo_blocking ) {
|
|
$geo_blocking = new ARKSP_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 = ARKSP_Helper::get_client_ip();
|
|
|
|
// Log the blocked access attempt.
|
|
ARKSP_Activity_Log::log(
|
|
ARKSP_Activity_Log::EVENT_IP_BLOCKED,
|
|
$ip,
|
|
null,
|
|
__( 'Admin access denied: country/IP not allowed', 'arkhost-security-pack' )
|
|
);
|
|
|
|
status_header( 403 );
|
|
nocache_headers();
|
|
|
|
wp_die(
|
|
esc_html__( 'Admin access is not permitted from your location.', 'arkhost-security-pack' ),
|
|
esc_html__( 'Access Denied', 'arkhost-security-pack' ),
|
|
array(
|
|
'response' => 403,
|
|
'back_link' => false,
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handle failed login attempt.
|
|
*
|
|
* @param string $username Username attempted.
|
|
*/
|
|
public function handle_failed_login( $username ) {
|
|
if ( ! ARKSP_Plugin::get_setting( 'login_limit_enabled', true ) ) {
|
|
return;
|
|
}
|
|
|
|
$ip = ARKSP_Helper::get_client_ip();
|
|
|
|
if ( ! $ip ) {
|
|
return;
|
|
}
|
|
|
|
// Check if IP is whitelisted.
|
|
$ip_control = arksp()->get_component( 'ip_control' );
|
|
if ( $ip_control && $ip_control->is_whitelisted( $ip ) ) {
|
|
ARKSP_Activity_Log::log( ARKSP_Activity_Log::EVENT_LOGIN_FAILED, $ip, $username, __( 'Failed login (whitelisted IP)', 'arkhost-security-pack' ) );
|
|
return;
|
|
}
|
|
|
|
// Log the failed attempt.
|
|
ARKSP_Activity_Log::log( ARKSP_Activity_Log::EVENT_LOGIN_FAILED, $ip, $username );
|
|
|
|
// Increment failed attempts counter.
|
|
$attempts = $this->increment_failed_attempts( $ip );
|
|
|
|
// Check if lockout threshold reached.
|
|
$max_attempts = ARKSP_Plugin::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 = ARKSP_Helper::get_client_ip();
|
|
|
|
// Log the successful login.
|
|
ARKSP_Activity_Log::log( ARKSP_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 ( ARKSP_Plugin::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 = ARKSP_Plugin::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, '_arksp_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, '_arksp_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', 'arkhost-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.', 'arkhost-security-pack' ),
|
|
$user->user_login,
|
|
$site_name
|
|
) . "\n\n";
|
|
|
|
/* translators: %s: Username */
|
|
$message .= sprintf( __( 'Username: %s', 'arkhost-security-pack' ), $user->user_login ) . "\n";
|
|
/* translators: %s: IP address */
|
|
$message .= sprintf( __( 'IP Address: %s', 'arkhost-security-pack' ), $ip ) . "\n";
|
|
/* translators: %s: Login time */
|
|
$message .= sprintf( __( 'Time: %s', 'arkhost-security-pack' ), current_time( 'mysql' ) ) . "\n\n";
|
|
$message .= __( 'If this was not you, please secure your account immediately.', 'arkhost-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.', 'arkhost-security-pack' );
|
|
}
|
|
|
|
/**
|
|
* Replace specific authentication errors with a generic message.
|
|
*
|
|
* Runs at very late priority on the authenticate filter to catch WordPress's
|
|
* own specific error messages (invalid_username, incorrect_password, etc.)
|
|
* and replace them before they reach the login page rendering.
|
|
*
|
|
* @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 genericize_auth_error( $user, $username, $password ) {
|
|
if ( is_wp_error( $user ) ) {
|
|
$specific_codes = array( 'invalid_username', 'invalid_email', 'incorrect_password', 'invalidcombo' );
|
|
|
|
if ( in_array( $user->get_error_code(), $specific_codes, true ) ) {
|
|
return new WP_Error(
|
|
'authentication_failed',
|
|
__( 'Invalid username or password.', 'arkhost-security-pack' )
|
|
);
|
|
}
|
|
}
|
|
|
|
return $user;
|
|
}
|
|
|
|
/**
|
|
* 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 ( ! ARKSP_Plugin::get_setting( 'login_limit_enabled', true ) ) {
|
|
return $user;
|
|
}
|
|
|
|
if ( empty( $username ) ) {
|
|
return $user;
|
|
}
|
|
|
|
$ip = ARKSP_Helper::get_client_ip();
|
|
|
|
if ( ! $ip ) {
|
|
return $user;
|
|
}
|
|
|
|
// Check if IP is whitelisted.
|
|
$ip_control = arksp()->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) ARKSP_Plugin::get_setting( 'login_max_attempts', 5 );
|
|
$lockout_duration = (int) ARKSP_Plugin::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(
|
|
'arksp_locked_out',
|
|
sprintf(
|
|
/* translators: %s: Time remaining */
|
|
__( 'Too many failed login attempts. Please try again in %s.', 'arkhost-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;
|
|
|
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Security data must be real-time.
|
|
$existing = $wpdb->get_row(
|
|
$wpdb->prepare(
|
|
"SELECT * FROM {$wpdb->prefix}arksp_lockouts WHERE ip_address = %s",
|
|
$ip
|
|
)
|
|
);
|
|
|
|
if ( $existing ) {
|
|
$new_count = $existing->failed_attempts + 1;
|
|
|
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Security data must be real-time.
|
|
$wpdb->update(
|
|
$wpdb->prefix . 'arksp_lockouts',
|
|
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,WordPress.DB.DirectDatabaseQuery.NoCaching -- Security data must be real-time.
|
|
$wpdb->insert(
|
|
$wpdb->prefix . 'arksp_lockouts',
|
|
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;
|
|
|
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Security data must be real-time.
|
|
$wpdb->delete(
|
|
$wpdb->prefix . 'arksp_lockouts',
|
|
array( 'ip_address' => $ip ),
|
|
array( '%s' )
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Lockout an IP address.
|
|
*
|
|
* @param string $ip IP address.
|
|
*/
|
|
private function lockout_ip( $ip ) {
|
|
global $wpdb;
|
|
|
|
$duration = (int) ARKSP_Plugin::get_setting( 'login_lockout_duration', 15 );
|
|
// Store Unix timestamp to avoid timezone issues.
|
|
$lockout_until = time() + ( $duration * 60 );
|
|
|
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Security data must be real-time.
|
|
$wpdb->update(
|
|
$wpdb->prefix . 'arksp_lockouts',
|
|
array(
|
|
'lockout_until' => $lockout_until,
|
|
'updated_at' => current_time( 'mysql' ),
|
|
),
|
|
array( 'ip_address' => $ip ),
|
|
array( '%d', '%s' ),
|
|
array( '%s' )
|
|
);
|
|
|
|
// Log the lockout.
|
|
ARKSP_Activity_Log::log(
|
|
ARKSP_Activity_Log::EVENT_LOCKOUT,
|
|
$ip,
|
|
null,
|
|
sprintf(
|
|
/* translators: %d: Duration in minutes */
|
|
__( 'Locked out for %d minutes', 'arkhost-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 ( ! ARKSP_Plugin::get_setting( 'auto_blacklist_enabled', false ) ) {
|
|
return;
|
|
}
|
|
|
|
$threshold = (int) ARKSP_Plugin::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.
|
|
ARKSP_Activity_Log::log(
|
|
ARKSP_Activity_Log::EVENT_IP_BLOCKED,
|
|
$ip,
|
|
null,
|
|
sprintf(
|
|
/* translators: %d: Number of lockouts */
|
|
__( 'Auto-blacklisted after %d lockouts', 'arkhost-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;
|
|
|
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Security data must be real-time.
|
|
$count = $wpdb->get_var(
|
|
$wpdb->prepare(
|
|
"SELECT COUNT(*) FROM {$wpdb->prefix}arksp_activity_log WHERE ip_address = %s AND event_type = %s",
|
|
$ip,
|
|
ARKSP_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 = ARKSP_Plugin::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 );
|
|
|
|
ARKSP_Plugin::update_setting( 'ip_blacklist', $new_blacklist );
|
|
|
|
// Clear the IP control cache.
|
|
$ip_control = arksp()->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;
|
|
|
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Security data must be real-time.
|
|
return $wpdb->get_row(
|
|
$wpdb->prepare(
|
|
"SELECT * FROM {$wpdb->prefix}arksp_lockouts 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'] ) ? sanitize_text_field( wp_unslash( $_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, '/' ) . '/arksp-404-' . wp_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( 'arksp_login_' . $this->custom_login_slug );
|
|
|
|
setcookie(
|
|
$this->cookie_name,
|
|
$cookie_value,
|
|
time() + DAY_IN_SECONDS,
|
|
defined( 'COOKIEPATH' ) ? COOKIEPATH : '/',
|
|
defined( 'COOKIE_DOMAIN' ) ? 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 ( ! ARKSP_Plugin::get_setting( 'login_limit_enabled', true ) ) {
|
|
return false;
|
|
}
|
|
|
|
$ip = ARKSP_Helper::get_client_ip();
|
|
if ( ! $ip ) {
|
|
return false;
|
|
}
|
|
|
|
// Check whitelist first.
|
|
$ip_control = arksp()->get_component( 'ip_control' );
|
|
if ( $ip_control && $ip_control->is_whitelisted( $ip ) ) {
|
|
return false;
|
|
}
|
|
|
|
// Check lockout status directly from database.
|
|
global $wpdb;
|
|
|
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Security data must be real-time.
|
|
$lockout = $wpdb->get_row(
|
|
$wpdb->prepare(
|
|
"SELECT * FROM {$wpdb->prefix}arksp_lockouts WHERE ip_address = %s",
|
|
$ip
|
|
)
|
|
);
|
|
|
|
if ( ! $lockout ) {
|
|
return false;
|
|
}
|
|
|
|
$max_attempts = (int) ARKSP_Plugin::get_setting( 'login_max_attempts', 5 );
|
|
$lockout_minutes = (int) ARKSP_Plugin::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() {
|
|
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.', 'arkhost-security-pack' ),
|
|
absint( ARKSP_Plugin::get_setting( 'login_lockout_duration', 15 ) )
|
|
),
|
|
esc_html__( 'Access Denied', 'arkhost-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'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
|
|
$request_path = trim( wp_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( wp_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( 'arksp_login_' . $this->custom_login_slug );
|
|
$cookie_value = isset( $_COOKIE[ $this->cookie_name ] ) ? sanitize_text_field( wp_unslash( $_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() {
|
|
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( 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( '/arksp-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;
|
|
}
|
|
|
|
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( 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( 'arksp_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.', 'arkhost-security-pack' ),
|
|
esc_html__( '404 Not Found', 'arkhost-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="arksp-hp-field" style="position:absolute;left:-9999px;top:-9999px;">
|
|
<label for="arksp_hp_email"><?php esc_html_e( 'Leave this field empty', 'arkhost-security-pack' ); ?></label>
|
|
<input type="text" name="arksp_hp_email" id="arksp_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['arksp_hp_email'] ) ) {
|
|
$ip = ARKSP_Helper::get_client_ip();
|
|
|
|
ARKSP_Activity_Log::log(
|
|
ARKSP_Activity_Log::EVENT_LOGIN_FAILED,
|
|
$ip,
|
|
$username,
|
|
__( 'Honeypot triggered', 'arkhost-security-pack' )
|
|
);
|
|
|
|
// Auto-block this IP using configurable duration.
|
|
$ip_control = arksp()->get_component( 'ip_control' );
|
|
$ban_duration = (int) ARKSP_Plugin::get_setting( 'honeypot_ban_duration', 60 );
|
|
if ( $ip_control ) {
|
|
$ip_control->auto_block_ip( $ip, $ban_duration, __( 'Honeypot triggered', 'arkhost-security-pack' ) );
|
|
}
|
|
|
|
return new WP_Error(
|
|
'arksp_honeypot',
|
|
__( 'Authentication failed.', 'arkhost-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['arksp_hp_email'] ) ) {
|
|
$errors->add(
|
|
'arksp_honeypot',
|
|
__( 'Registration failed.', 'arkhost-security-pack' )
|
|
);
|
|
|
|
// Auto-block this IP using configurable duration.
|
|
$ip = ARKSP_Helper::get_client_ip();
|
|
$ip_control = arksp()->get_component( 'ip_control' );
|
|
$ban_duration = (int) ARKSP_Plugin::get_setting( 'honeypot_ban_duration', 60 );
|
|
if ( $ip_control && $ip ) {
|
|
$ip_control->auto_block_ip( $ip, $ban_duration, __( 'Honeypot triggered on registration', 'arkhost-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 ( ! ARKSP_Plugin::get_setting( 'email_alerts_enabled', false ) ) {
|
|
return;
|
|
}
|
|
|
|
$email = ARKSP_Plugin::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 = ARKSP_Plugin::get_setting( 'email_alert_threshold', 3 );
|
|
if ( $attempts < $threshold ) {
|
|
return;
|
|
}
|
|
|
|
$subject = sprintf(
|
|
/* translators: %s: Site name */
|
|
__( '[%s] Multiple Failed Login Attempts', 'arkhost-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.", 'arkhost-security-pack' ),
|
|
$attempts,
|
|
$ip,
|
|
$username ? $username : __( '(empty)', 'arkhost-security-pack' ),
|
|
$site_url
|
|
);
|
|
break;
|
|
|
|
case 'lockout':
|
|
$subject = sprintf(
|
|
/* translators: %s: Site name */
|
|
__( '[%s] IP Address Locked Out', 'arkhost-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", 'arkhost-security-pack' ),
|
|
$ip,
|
|
$site_url
|
|
);
|
|
break;
|
|
|
|
case 'auto_blacklist':
|
|
$subject = sprintf(
|
|
/* translators: %s: Site name */
|
|
__( '[%s] IP Permanently Blacklisted', 'arkhost-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.", 'arkhost-security-pack' ),
|
|
$ip,
|
|
$attempts,
|
|
$site_url
|
|
);
|
|
break;
|
|
|
|
default:
|
|
return;
|
|
}
|
|
|
|
wp_mail( $email, $subject, $message );
|
|
}
|
|
}
|