login-security.php

I’ll build this as a standalone inc/security/login-security.php module for your RX theme, with safe defaults, WordPress hooks, login hardening, rate limiting, bot checks, headers, XML-RPC controls, REST restrictions, audit logging, and admin settings constants.

Below is an advanced inc/security/login-security.php file for your RX theme. It adds many safe login/security functions without needing a plugin.

Add this file here:

rx-theme/
└── inc/
    └── security/
        └── login-security.php

Then load it in your functions.php:

require_once get_template_directory() . '/inc/security/login-security.php';

Now create this file:

<?php
/**
 * RX Theme - Login Security Module
 *
 * Path: inc/security/login-security.php
 *
 * Features:
 * - Hide detailed login errors
 * - Limit failed login attempts
 * - Temporary IP + username lockout
 * - Login honeypot field
 * - Bot user-agent blocking
 * - XML-RPC hardening
 * - REST API user endpoint protection
 * - Author enumeration blocking
 * - Disable file editor
 * - Remove WordPress version exposure
 * - Security headers
 * - Login page noindex
 * - Username protection
 * - Registration spam protection
 * - Stronger password validation
 * - Application password control
 * - Login audit hooks
 * - Optional admin email alert after lockout
 */

defined( 'ABSPATH' ) || exit;

if ( ! class_exists( 'RX_Login_Security' ) ) :

final class RX_Login_Security {

	/**
	 * Version.
	 */
	const VERSION = '1.0.0';

	/**
	 * Transient prefix.
	 */
	const PREFIX = 'rx_login_sec_';

	/**
	 * Default max failed attempts.
	 */
	const DEFAULT_MAX_ATTEMPTS = 5;

	/**
	 * Default lockout time in seconds.
	 */
	const DEFAULT_LOCKOUT_SECONDS = 1800; // 30 minutes.

	/**
	 * Constructor.
	 */
	private function __construct() {
		$this->define_constants();
		$this->hooks();
	}

	/**
	 * Init singleton.
	 */
	public static function init() {
		static $instance = null;

		if ( null === $instance ) {
			$instance = new self();
		}

		return $instance;
	}

	/**
	 * Define safe constants if not already defined.
	 */
	private function define_constants() {
		$this->maybe_define( 'RX_LOGIN_SECURITY_MAX_ATTEMPTS', self::DEFAULT_MAX_ATTEMPTS );
		$this->maybe_define( 'RX_LOGIN_SECURITY_LOCKOUT_SECONDS', self::DEFAULT_LOCKOUT_SECONDS );
		$this->maybe_define( 'RX_LOGIN_SECURITY_BLOCK_XMLRPC', true );
		$this->maybe_define( 'RX_LOGIN_SECURITY_DISABLE_APP_PASSWORDS', false );
		$this->maybe_define( 'RX_LOGIN_SECURITY_BLOCK_AUTHOR_ENUM', true );
		$this->maybe_define( 'RX_LOGIN_SECURITY_HIDE_WP_VERSION', true );
		$this->maybe_define( 'RX_LOGIN_SECURITY_SEND_LOCKOUT_EMAIL', false );
		$this->maybe_define( 'RX_LOGIN_SECURITY_STRONG_PASSWORDS', true );
		$this->maybe_define( 'RX_LOGIN_SECURITY_BLOCK_REST_USERS_PUBLIC', true );
		$this->maybe_define( 'RX_LOGIN_SECURITY_ENABLE_HEADERS', true );
		$this->maybe_define( 'RX_LOGIN_SECURITY_LOG_EVENTS', false );
	}

	/**
	 * Define constant safely.
	 */
	private function maybe_define( $name, $value ) {
		if ( ! defined( $name ) ) {
			define( $name, $value );
		}
	}

	/**
	 * Register hooks.
	 */
	private function hooks() {
		add_filter( 'login_errors', array( $this, 'generic_login_error' ) );
		add_filter( 'authenticate', array( $this, 'check_login_lockout_before_auth' ), 1, 3 );
		add_action( 'wp_login_failed', array( $this, 'handle_failed_login' ) );
		add_action( 'wp_login', array( $this, 'handle_successful_login' ), 10, 2 );

		add_action( 'login_form', array( $this, 'add_login_honeypot' ) );
		add_filter( 'authenticate', array( $this, 'check_login_honeypot' ), 5, 3 );

		add_action( 'login_init', array( $this, 'block_bad_login_requests' ) );
		add_action( 'login_head', array( $this, 'login_page_noindex' ) );

		add_filter( 'xmlrpc_enabled', array( $this, 'maybe_disable_xmlrpc' ) );
		add_filter( 'xmlrpc_methods', array( $this, 'remove_xmlrpc_methods' ) );

		add_action( 'init', array( $this, 'block_author_enumeration' ), 1 );
		add_filter( 'rest_endpoints', array( $this, 'protect_rest_user_endpoints' ) );

		add_action( 'send_headers', array( $this, 'send_security_headers' ) );

		add_filter( 'the_generator', '__return_empty_string' );
		add_action( 'init', array( $this, 'remove_wp_version_exposure' ) );

		add_filter( 'wp_is_application_passwords_available', array( $this, 'maybe_disable_application_passwords' ) );

		add_action( 'user_profile_update_errors', array( $this, 'validate_profile_password_strength' ), 10, 3 );
		add_filter( 'registration_errors', array( $this, 'validate_registration_security' ), 10, 3 );

		add_action( 'init', array( $this, 'security_runtime_defines' ), 0 );
	}

	/**
	 * Runtime WordPress security defines.
	 */
	public function security_runtime_defines() {
		if ( ! defined( 'DISALLOW_FILE_EDIT' ) ) {
			define( 'DISALLOW_FILE_EDIT', true );
		}
	}

	/**
	 * Generic login error.
	 */
	public function generic_login_error() {
		return esc_html__( 'Login failed. Please check your details and try again.', 'rx-theme' );
	}

	/**
	 * Get real-ish visitor IP.
	 */
	private function get_ip() {
		$ip_keys = array(
			'HTTP_CF_CONNECTING_IP',
			'HTTP_X_REAL_IP',
			'HTTP_X_FORWARDED_FOR',
			'REMOTE_ADDR',
		);

		foreach ( $ip_keys as $key ) {
			if ( empty( $_SERVER[ $key ] ) ) {
				continue;
			}

			$value = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) );

			if ( 'HTTP_X_FORWARDED_FOR' === $key ) {
				$parts = explode( ',', $value );
				$value = trim( $parts[0] );
			}

			if ( filter_var( $value, FILTER_VALIDATE_IP ) ) {
				return $value;
			}
		}

		return '0.0.0.0';
	}

	/**
	 * Get user agent.
	 */
	private function get_user_agent() {
		if ( empty( $_SERVER['HTTP_USER_AGENT'] ) ) {
			return '';
		}

		return substr(
			sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ),
			0,
			255
		);
	}

	/**
	 * Get login identity.
	 */
	private function get_identity_key( $username = '' ) {
		$ip       = $this->get_ip();
		$username = is_string( $username ) ? strtolower( sanitize_user( $username ) ) : '';
		$raw      = $ip . '|' . $username;

		return self::PREFIX . 'identity_' . md5( $raw );
	}

	/**
	 * Get IP-only key.
	 */
	private function get_ip_key() {
		return self::PREFIX . 'ip_' . md5( $this->get_ip() );
	}

	/**
	 * Get transient data.
	 */
	private function get_attempt_data( $username = '' ) {
		$key  = $this->get_identity_key( $username );
		$data = get_transient( $key );

		if ( ! is_array( $data ) ) {
			$data = array(
				'attempts'  => 0,
				'locked'    => false,
				'last_time' => 0,
			);
		}

		return $data;
	}

	/**
	 * Save attempt data.
	 */
	private function save_attempt_data( $username, $data, $ttl = null ) {
		$key = $this->get_identity_key( $username );

		if ( null === $ttl ) {
			$ttl = absint( RX_LOGIN_SECURITY_LOCKOUT_SECONDS );
		}

		set_transient( $key, $data, $ttl );
	}

	/**
	 * Delete attempt data after success.
	 */
	private function clear_attempt_data( $username ) {
		delete_transient( $this->get_identity_key( $username ) );
		delete_transient( $this->get_ip_key() );
	}

	/**
	 * Check lockout before WordPress authenticates.
	 */
	public function check_login_lockout_before_auth( $user, $username, $password ) {
		if ( empty( $username ) ) {
			return $user;
		}

		$data = $this->get_attempt_data( $username );

		if ( ! empty( $data['locked'] ) ) {
			$remaining = $this->get_lockout_remaining_time( $username );

			return new WP_Error(
				'rx_login_locked',
				sprintf(
					esc_html__( 'Too many failed login attempts. Please try again later.', 'rx-theme' ),
					absint( $remaining )
				)
			);
		}

		return $user;
	}

	/**
	 * Get lockout remaining time.
	 */
	private function get_lockout_remaining_time( $username ) {
		$data = $this->get_attempt_data( $username );

		if ( empty( $data['locked_until'] ) ) {
			return 0;
		}

		return max( 0, absint( $data['locked_until'] ) - time() );
	}

	/**
	 * Handle failed login.
	 */
	public function handle_failed_login( $username ) {
		$username = sanitize_user( $username );
		$data     = $this->get_attempt_data( $username );

		$data['attempts']  = absint( $data['attempts'] ) + 1;
		$data['last_time'] = time();
		$data['ip']        = $this->get_ip();
		$data['ua']        = $this->get_user_agent();

		$max_attempts = absint( RX_LOGIN_SECURITY_MAX_ATTEMPTS );

		if ( $data['attempts'] >= $max_attempts ) {
			$data['locked']       = true;
			$data['locked_until'] = time() + absint( RX_LOGIN_SECURITY_LOCKOUT_SECONDS );

			$this->maybe_send_lockout_email( $username, $data );
			$this->log_event( 'lockout', $username, $data );
		} else {
			$this->log_event( 'failed_login', $username, $data );
		}

		$this->save_attempt_data( $username, $data );
	}

	/**
	 * Handle successful login.
	 */
	public function handle_successful_login( $user_login, $user ) {
		$this->clear_attempt_data( $user_login );

		$this->log_event(
			'successful_login',
			$user_login,
			array(
				'user_id' => isset( $user->ID ) ? absint( $user->ID ) : 0,
				'ip'      => $this->get_ip(),
				'ua'      => $this->get_user_agent(),
				'time'    => time(),
			)
		);
	}

	/**
	 * Add honeypot field to login form.
	 */
	public function add_login_honeypot() {
		?>
		<p style="display:none !important;">
			<label for="rx_login_extra_field">
				<?php esc_html_e( 'Leave this field empty', 'rx-theme' ); ?>
			</label>
			<input type="text" name="rx_login_extra_field" id="rx_login_extra_field" value="" autocomplete="off" tabindex="-1" />
		</p>
		<?php
	}

	/**
	 * Check honeypot field.
	 */
	public function check_login_honeypot( $user, $username, $password ) {
		if ( ! empty( $_POST['rx_login_extra_field'] ) ) {
			$this->log_event(
				'honeypot_block',
				$username,
				array(
					'ip' => $this->get_ip(),
					'ua' => $this->get_user_agent(),
				)
			);

			return new WP_Error(
				'rx_honeypot_block',
				esc_html__( 'Login failed. Please try again.', 'rx-theme' )
			);
		}

		return $user;
	}

	/**
	 * Block bad login requests.
	 */
	public function block_bad_login_requests() {
		$ua = strtolower( $this->get_user_agent() );

		$bad_agents = array(
			'nikto',
			'sqlmap',
			'acunetix',
			'nessus',
			'masscan',
			'nmap',
			'zgrab',
			'python-requests',
			'libwww-perl',
			'wget',
			'curl',
		);

		foreach ( $bad_agents as $bad ) {
			if ( false !== strpos( $ua, $bad ) ) {
				$this->log_event(
					'bad_user_agent_block',
					'',
					array(
						'ip' => $this->get_ip(),
						'ua' => $this->get_user_agent(),
					)
				);

				status_header( 403 );
				exit;
			}
		}

		if ( isset( $_GET['action'] ) && 'register' === sanitize_key( $_GET['action'] ) ) {
			$this->maybe_block_registration_by_option();
		}
	}

	/**
	 * Respect WordPress registration option.
	 */
	private function maybe_block_registration_by_option() {
		if ( ! get_option( 'users_can_register' ) ) {
			status_header( 403 );
			wp_die(
				esc_html__( 'Registration is disabled.', 'rx-theme' ),
				esc_html__( 'Forbidden', 'rx-theme' ),
				array( 'response' => 403 )
			);
		}
	}

	/**
	 * Login page noindex.
	 */
	public function login_page_noindex() {
		echo "<meta name='robots' content='noindex,nofollow,noarchive' />\n";
	}

	/**
	 * Disable XML-RPC.
	 */
	public function maybe_disable_xmlrpc( $enabled ) {
		if ( RX_LOGIN_SECURITY_BLOCK_XMLRPC ) {
			return false;
		}

		return $enabled;
	}

	/**
	 * Remove dangerous XML-RPC methods.
	 */
	public function remove_xmlrpc_methods( $methods ) {
		if ( ! is_array( $methods ) ) {
			return $methods;
		}

		$remove = array(
			'pingback.ping',
			'pingback.extensions.getPingbacks',
			'wp.getUsersBlogs',
			'wp.getUsers',
			'wp.getProfile',
		);

		foreach ( $remove as $method ) {
			if ( isset( $methods[ $method ] ) ) {
				unset( $methods[ $method ] );
			}
		}

		return $methods;
	}

	/**
	 * Block author enumeration.
	 */
	public function block_author_enumeration() {
		if ( ! RX_LOGIN_SECURITY_BLOCK_AUTHOR_ENUM ) {
			return;
		}

		if ( is_admin() ) {
			return;
		}

		if ( isset( $_GET['author'] ) && ! empty( $_GET['author'] ) ) {
			status_header( 403 );
			wp_die(
				esc_html__( 'Author enumeration is disabled.', 'rx-theme' ),
				esc_html__( 'Forbidden', 'rx-theme' ),
				array( 'response' => 403 )
			);
		}
	}

	/**
	 * Protect REST API user endpoints from public exposure.
	 */
	public function protect_rest_user_endpoints( $endpoints ) {
		if ( ! RX_LOGIN_SECURITY_BLOCK_REST_USERS_PUBLIC ) {
			return $endpoints;
		}

		if ( is_user_logged_in() && current_user_can( 'list_users' ) ) {
			return $endpoints;
		}

		$protected = array(
			'/wp/v2/users',
			'/wp/v2/users/(?P<id>[\d]+)',
		);

		foreach ( $protected as $route ) {
			if ( isset( $endpoints[ $route ] ) ) {
				unset( $endpoints[ $route ] );
			}
		}

		return $endpoints;
	}

	/**
	 * Send security headers.
	 */
	public function send_security_headers() {
		if ( ! RX_LOGIN_SECURITY_ENABLE_HEADERS ) {
			return;
		}

		if ( headers_sent() ) {
			return;
		}

		header( 'X-Content-Type-Options: nosniff' );
		header( 'X-Frame-Options: SAMEORIGIN' );
		header( 'X-XSS-Protection: 1; mode=block' );
		header( 'Referrer-Policy: strict-origin-when-cross-origin' );
		header( 'Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=()' );

		if ( is_ssl() ) {
			header( 'Strict-Transport-Security: max-age=31536000; includeSubDomains; preload' );
		}
	}

	/**
	 * Remove WP version exposure.
	 */
	public function remove_wp_version_exposure() {
		if ( ! RX_LOGIN_SECURITY_HIDE_WP_VERSION ) {
			return;
		}

		remove_action( 'wp_head', 'wp_generator' );
		remove_action( 'rss2_head', 'the_generator' );
		remove_action( 'commentsrss2_head', 'the_generator' );
		remove_action( 'rss_head', 'the_generator' );
		remove_action( 'rdf_header', 'the_generator' );
		remove_action( 'atom_head', 'the_generator' );
		remove_action( 'comments_atom_head', 'the_generator' );
	}

	/**
	 * Disable application passwords optionally.
	 */
	public function maybe_disable_application_passwords( $available ) {
		if ( RX_LOGIN_SECURITY_DISABLE_APP_PASSWORDS ) {
			return false;
		}

		return $available;
	}

	/**
	 * Validate stronger password on profile update.
	 */
	public function validate_profile_password_strength( $errors, $update, $user ) {
		if ( ! RX_LOGIN_SECURITY_STRONG_PASSWORDS ) {
			return;
		}

		if ( empty( $_POST['pass1'] ) ) {
			return;
		}

		$password = (string) wp_unslash( $_POST['pass1'] );

		$message = $this->password_strength_message( $password );

		if ( $message ) {
			$errors->add( 'rx_weak_password', $message );
		}
	}

	/**
	 * Validate registration security.
	 */
	public function validate_registration_security( $errors, $sanitized_user_login, $user_email ) {
		$username = strtolower( sanitize_user( $sanitized_user_login ) );

		$blocked_usernames = array(
			'admin',
			'administrator',
			'root',
			'test',
			'user',
			'support',
			'webmaster',
			'info',
			'owner',
			'manager',
		);

		if ( in_array( $username, $blocked_usernames, true ) ) {
			$errors->add(
				'rx_blocked_username',
				esc_html__( 'This username is not allowed. Please choose another username.', 'rx-theme' )
			);
		}

		if ( ! is_email( $user_email ) ) {
			$errors->add(
				'rx_invalid_email',
				esc_html__( 'Please enter a valid email address.', 'rx-theme' )
			);
		}

		$domain = '';
		if ( false !== strpos( $user_email, '@' ) ) {
			$parts  = explode( '@', $user_email );
			$domain = strtolower( end( $parts ) );
		}

		$blocked_domains = apply_filters(
			'rx_login_security_blocked_email_domains',
			array(
				'example.com',
				'test.com',
				'mailinator.com',
				'tempmail.com',
				'10minutemail.com',
				'guerrillamail.com',
			)
		);

		if ( $domain && in_array( $domain, $blocked_domains, true ) ) {
			$errors->add(
				'rx_blocked_email_domain',
				esc_html__( 'This email domain is not allowed.', 'rx-theme' )
			);
		}

		return $errors;
	}

	/**
	 * Password strength message.
	 */
	private function password_strength_message( $password ) {
		$password = (string) $password;

		if ( strlen( $password ) < 12 ) {
			return esc_html__( 'Password must be at least 12 characters long.', 'rx-theme' );
		}

		if ( ! preg_match( '/[A-Z]/', $password ) ) {
			return esc_html__( 'Password must include at least one uppercase letter.', 'rx-theme' );
		}

		if ( ! preg_match( '/[a-z]/', $password ) ) {
			return esc_html__( 'Password must include at least one lowercase letter.', 'rx-theme' );
		}

		if ( ! preg_match( '/[0-9]/', $password ) ) {
			return esc_html__( 'Password must include at least one number.', 'rx-theme' );
		}

		if ( ! preg_match( '/[^A-Za-z0-9]/', $password ) ) {
			return esc_html__( 'Password must include at least one special character.', 'rx-theme' );
		}

		return '';
	}

	/**
	 * Send lockout email alert.
	 */
	private function maybe_send_lockout_email( $username, $data ) {
		if ( ! RX_LOGIN_SECURITY_SEND_LOCKOUT_EMAIL ) {
			return;
		}

		$admin_email = get_option( 'admin_email' );

		if ( ! is_email( $admin_email ) ) {
			return;
		}

		$subject = sprintf(
			/* translators: %s: site name */
			esc_html__( '[%s] Login lockout alert', 'rx-theme' ),
			wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES )
		);

		$message  = "RX Theme Login Security Alert\n\n";
		$message .= "A login identity has been temporarily locked.\n\n";
		$message .= 'Username: ' . sanitize_text_field( $username ) . "\n";
		$message .= 'IP: ' . sanitize_text_field( $data['ip'] ?? '' ) . "\n";
		$message .= 'User Agent: ' . sanitize_text_field( $data['ua'] ?? '' ) . "\n";
		$message .= 'Attempts: ' . absint( $data['attempts'] ?? 0 ) . "\n";
		$message .= 'Time: ' . current_time( 'mysql' ) . "\n";

		wp_mail( $admin_email, $subject, $message );
	}

	/**
	 * Optional security logging.
	 */
	private function log_event( $event, $username = '', $context = array() ) {
		if ( ! RX_LOGIN_SECURITY_LOG_EVENTS ) {
			return;
		}

		$upload_dir = wp_upload_dir();

		if ( empty( $upload_dir['basedir'] ) ) {
			return;
		}

		$dir = trailingslashit( $upload_dir['basedir'] ) . 'rx-security-logs';

		if ( ! wp_mkdir_p( $dir ) ) {
			return;
		}

		$htaccess = trailingslashit( $dir ) . '.htaccess';

		if ( ! file_exists( $htaccess ) ) {
			file_put_contents( $htaccess, "Deny from all\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		}

		$file = trailingslashit( $dir ) . 'login-security.log';

		$line = wp_json_encode(
			array(
				'time'     => current_time( 'mysql' ),
				'event'    => sanitize_key( $event ),
				'username' => sanitize_user( $username ),
				'ip'       => $this->get_ip(),
				'context'  => $context,
			)
		);

		if ( $line ) {
			file_put_contents( $file, $line . PHP_EOL, FILE_APPEND | LOCK_EX ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		}
	}
}

endif;

RX_Login_Security::init();

Optional: control features from wp-config.php before WordPress loads:

define( 'RX_LOGIN_SECURITY_MAX_ATTEMPTS', 5 );
define( 'RX_LOGIN_SECURITY_LOCKOUT_SECONDS', 1800 );
define( 'RX_LOGIN_SECURITY_BLOCK_XMLRPC', true );
define( 'RX_LOGIN_SECURITY_DISABLE_APP_PASSWORDS', false );
define( 'RX_LOGIN_SECURITY_SEND_LOCKOUT_EMAIL', false );
define( 'RX_LOGIN_SECURITY_LOG_EVENTS', false );
define( 'RX_LOGIN_SECURITY_STRONG_PASSWORDS', true );
define( 'RX_LOGIN_SECURITY_ENABLE_HEADERS', true );

For your RX theme, this is a strong and safe login-security foundation. It should not break normal WordPress login, but after adding it, test login, logout, password reset, registration, REST API, and XML-RPC behavior.

Leave a Reply

Your email address will not be published. Required fields are marked *