*/ private static $state_cache = []; /** * Cached settings for this request. * * @var array|null */ private $settings = null; /** * Register hooks. */ public function register_hooks() { $settings = $this->get_settings(); if ( !$this->is_enabled( $settings ) ) { return; } $this->action( 'admin_init', [$this, 'check_wp_cron_status'] ); // Record failures from every authentication path. $this->action( 'wp_login_failed', [$this, 'record_failed_login'], 10, 2 ); $this->action( 'application_password_failed_authentication', [$this, 'record_failed_application_password'] ); // Clear the counter once the visitor proves they are legitimate. $this->action( 'wp_login', [$this, 'clear_failed_logins'], 10, 2 ); // Enforce. See AUTH_PRIORITY for why this priority matters. $this->filter( 'authenticate', [$this, 'block_authentication'], self::AUTH_PRIORITY, 3 ); // Deny the login screen outright so a locked-out visitor gets a clear // message instead of a generic credentials error. $this->action( 'login_init', [$this, 'guard_login_screen'] ); // Themed front-end login/registration pages. $this->action( 'template_redirect', [$this, 'guard_front_end_login'] ); // Tell the visitor how many tries they have left. $this->filter( 'login_errors', [$this, 'append_attempts_remaining'] ); } /* --------------------------------------------------------------------- * Enforcement * ------------------------------------------------------------------- */ /** * Reject authentication for a locked-out or blocked address. * * @param \WP_User|\WP_Error|null $user Result so far. * @param string $username Submitted username. * @param string $password Submitted password. * * @return \WP_User|\WP_Error|null */ public function block_authentication( $user, $username = '', $password = '' ) { // Nothing to do for an already-failed attempt with no credentials. if ( '' === (string) $username && '' === (string) $password ) { return $user; } if ( $this->is_exempt() ) { return $user; } // Manually denied addresses are refused on every authentication path. // guard_login_screen() only covers wp-login.php and a handful of themed // front-end pages, so checking the deny list there alone left XML-RPC, // Application Passwords and any other theme login form able to sign in // with valid credentials from a permanently blocked address. if ( $this->is_denied_ip() ) { \ThemePaste\SecureAdmin\Classes\ThreatLog::report( 'ip_denied' ); return new \WP_Error( 'tpsa_ip_denied', __( 'Your IP address is not permitted to sign in to this site.', 'admin-safety-guard' ) ); } $state = $this->get_state(); if ( $state['blocked'] ) { return new \WP_Error( 'tpsa_ip_blocked', $this->blocked_message() ); } if ( $state['locked'] ) { return new \WP_Error( 'tpsa_ip_locked_out', $this->lockout_message( $state['minutes_left'] ) ); } return $user; } /** * Stop a locked-out or denied address from reaching the login screen. * * @return void */ public function guard_login_screen() { if ( $this->is_exempt() ) { return; } // Manually denied addresses never see the form at all. if ( $this->is_denied_ip() ) { \ThemePaste\SecureAdmin\Classes\ThreatLog::report( 'ip_denied' ); $this->deny( __( 'Your IP address is not permitted to sign in to this site.', 'admin-safety-guard' ), __( 'Login Blocked', 'admin-safety-guard' ) ); } $state = $this->get_state(); if ( $state['blocked'] ) { $this->deny( $this->blocked_message() ); } elseif ( $state['locked'] ) { $this->deny( $this->lockout_message( $state['minutes_left'] ) ); } } /** * Same protection for themed login/registration pages on the front end. * * @return void */ public function guard_front_end_login() { // Cheap bail-out first: this runs on every front-end request, so no // database work unless the request actually looks like a login page. if ( !is_page( array( 'login', 'register', 'sign-in', 'signin' ) ) ) { return; } $this->guard_login_screen(); } /** * Append "X attempts remaining" to the login error output. * * @param string $errors Existing error markup. * @return string */ public function append_attempts_remaining( $errors ) { $settings = $this->get_settings(); // Field default is on, so an option saved before this setting existed // should behave as on rather than silently off. $show_remaining = array_key_exists( 'show-remaining', $settings ) ? !empty( $settings['show-remaining'] ) : true; if ( !$show_remaining || $this->is_exempt() ) { return $errors; } $state = $this->get_state(); if ( $state['blocked'] || $state['locked'] || $state['attempts_left'] < 1 ) { return $errors; } $notice = sprintf( /* translators: %d: number of sign-in attempts left before lockout. */ _n( 'You have %d attempt remaining before this IP address is locked out.', 'You have %d attempts remaining before this IP address is locked out.', $state['attempts_left'], 'admin-safety-guard' ), $state['attempts_left'] ); return $errors . '
' . esc_html( $notice ) . '
'; } /* --------------------------------------------------------------------- * Recording * ------------------------------------------------------------------- */ /** * Record a failed sign-in for the current IP address. * * Attempts are tracked per IP ONLY. An earlier version keyed the counter on * IP + User-Agent, which meant an attacker who randomised the User-Agent * header started a fresh counter on every request and was never locked out. * * @param string $username Attempted username (stored for the log only). * @param \WP_Error|null $error Error from the authentication stack. * * @return void */ public function record_failed_login( $username, $error = null ) { if ( $this->is_exempt() ) { return; } $settings = $this->get_settings(); $max_attempts = max( 1, (int) ( $settings['max-attempts'] ?? 3 ) ); $max_lockouts = max( 1, (int) ( $settings['max-lockout'] ?? 3 ) ); $table = get_tpsa_db_table_name( 'failed_logins' ); if ( !$this->table_exists( $table ) ) { return; } global $wpdb; $ip = $this->get_ip_address(); $user_agent = $this->get_user_agent(); $now = current_time( 'mysql' ); $username = is_string( $username ) ? substr( sanitize_user( $username, true ), 0, 100 ) : ''; // Newest row for this IP. Installs upgraded from the IP + User-Agent // scheme can hold several rows per address; the most recent one carries // the live counter. $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE ip_address = %s ORDER BY id DESC LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $ip ) ); $attempts = 1; $lockouts = 0; $lockout_time = null; $first_seen = $now; if ( $row ) { $last_ts = !empty( $row->last_login_time ) ? strtotime( $row->last_login_time ) : 0; $window_expired = $last_ts && ( strtotime( $now ) - $last_ts ) > DAY_IN_SECONDS; if ( $window_expired ) { // Rolling 24-hour window: start fresh. $attempts = 1; $lockouts = 0; $lockout_time = null; $first_seen = $now; } else { $attempts = (int) $row->login_attempts + 1; $lockouts = (int) $row->lockouts; $lockout_time = $row->lockout_time; $first_seen = $row->first_login_time; } } // Threshold reached: open a lockout window and reset the attempt counter. $just_locked = false; if ( $attempts >= $max_attempts ) { $attempts = 0; $lockouts++; $lockout_time = $now; $just_locked = true; } if ( $row ) { $wpdb->update( $table, [ 'username' => $username, 'user_agent' => $user_agent, 'first_login_time' => $first_seen, 'last_login_time' => $now, 'login_attempts' => $attempts, 'lockouts' => $lockouts, 'lockout_time' => $lockout_time, ], ['id' => (int) $row->id], ['%s', '%s', '%s', '%s', '%d', '%d', '%s'], ['%d'] ); } else { $wpdb->insert( $table, [ 'username' => $username, 'user_agent' => $user_agent, 'ip_address' => $ip, 'first_login_time' => $first_seen, 'last_login_time' => $now, 'login_attempts' => $attempts, 'lockouts' => $lockouts, 'lockout_time' => $lockout_time, ], ['%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s'] ); } // Too many lockouts inside the window: escalate to a 24-hour block. if ( $just_locked && $lockouts >= $max_lockouts ) { $this->block_ip( $ip, $user_agent, $now ); } if ( $just_locked ) { \ThemePaste\SecureAdmin\Classes\ThreatLog::report( $lockouts >= $max_lockouts ? 'ip_blocked' : 'login_lockout', $username, $ip ); $this->maybe_notify_admin( $ip, $user_agent, $username, $now, $lockouts >= $max_lockouts ); } // The state changed; drop the cached copy. unset( self::$state_cache[$ip] ); } /** * Record a failed Application Password authentication. * * Application Passwords authenticate through the `determine_current_user` * filter rather than wp_authenticate(), so they never fire wp_login_failed. * Without this the REST API is an uncounted brute-force surface. * * @param \WP_Error $error Authentication error. * @return void */ public function record_failed_application_password( $error = null ) { $username = ''; if ( isset( $_SERVER['PHP_AUTH_USER'] ) ) { $username = sanitize_user( wp_unslash( $_SERVER['PHP_AUTH_USER'] ), true ); } $this->record_failed_login( $username ); } /** * Clear the failure counter for an address after a successful sign-in. * * Without this a legitimate user who mistypes their password stays one * attempt away from a lockout for the next 24 hours. * * @param string $user_login Username. * @param \WP_User $user User object. * * @return void */ public function clear_failed_logins( $user_login = '', $user = null ) { $table = get_tpsa_db_table_name( 'failed_logins' ); if ( !$this->table_exists( $table ) ) { return; } global $wpdb; $ip = $this->get_ip_address(); $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET login_attempts = 0, lockouts = 0, lockout_time = NULL WHERE ip_address = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $ip ) ); unset( self::$state_cache[$ip] ); } /** * Add an address to the 24-hour block table. * * @param string $ip IP address. * @param string $user_agent User agent string. * @param string $now Site-local MySQL datetime. * * @return void */ private function block_ip( $ip, $user_agent, $now ) { $table = get_tpsa_db_table_name( 'block_users' ); if ( !$this->table_exists( $table ) ) { return; } global $wpdb; $already = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE ip_address = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $ip ) ); if ( $already ) { return; } $wpdb->insert( $table, [ 'user_agent' => $user_agent, 'ip_address' => $ip, 'login_time' => $now, ], ['%s', '%s', '%s'] ); } /** * Email the site administrator about a lockout, if enabled. * * @param string $ip IP address. * @param string $user_agent User agent string. * @param string $username Attempted username. * @param string $now Site-local MySQL datetime. * @param bool $escalated Whether this lockout escalated to a 24h block. * * @return void */ private function maybe_notify_admin( $ip, $user_agent, $username, $now, $escalated ) { $settings = $this->get_settings(); if ( empty( $settings['notify-admin'] ) ) { return; } $admin_email = get_option( 'admin_email' ); if ( !is_email( $admin_email ) ) { return; } // Don't let a sustained attack turn into a mail flood. $throttle_key = 'tpsa_lockout_mail_' . md5( $ip ); if ( false !== get_transient( $throttle_key ) ) { return; } set_transient( $throttle_key, 1, HOUR_IN_SECONDS ); $site_name = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ); $subject = $escalated ? sprintf( /* translators: %s: site name. */ __( '[%s] An IP address has been blocked for 24 hours', 'admin-safety-guard' ), $site_name ) : sprintf( /* translators: %s: site name. */ __( '[%s] An IP address has been locked out', 'admin-safety-guard' ), $site_name ); // The IP, username and especially the User-Agent are attacker // controlled, so escape them before building an HTML email body. $body = sprintf( '%1$s
'; esc_html_e( 'Admin Safety Guard:', 'admin-safety-guard' ); echo ' '; esc_html_e( 'WP-Cron is disabled on this site, so 24-hour IP blocks will not be released automatically. Configure a real server cron job that calls wp-cron.php, or remove the DISABLE_WP_CRON constant from wp-config.php.', 'admin-safety-guard' ); echo '