init(); $login->init(); } public function defaults() { return array( 'enabled' => true, 'background_color' => '#111119', 'background_image' => '', 'users' => array(), 'safelist_ips' => '', 'safelist_usernames' => '', 'denylist_ips' => '', 'denylist_usernames' => '', 'ip_lockout_threshold' => 5, 'ip_lockout_window' => 15, 'ip_lockout_duration' => 30, 'ip_email_threshold' => 10, 'ip_email_window' => 60, 'block_xmlrpc' => false, ); } public function get_settings() { $settings = get_option(self::OPTION_NAME, array()); if (! is_array($settings)) { $settings = array(); } return wp_parse_args($settings, $this->defaults()); } public function sanitize_settings($input) { $input = is_array($input) ? $input : array(); $current = $this->get_settings(); $icons = array_keys(A360_Login_Avatar_Panel_Icons::all()); $users = isset($current['users']) && is_array($current['users']) ? $current['users'] : array(); $settings = $current; if (array_key_exists('enabled', $input)) { $settings['enabled'] = ! empty($input['enabled']); } if (array_key_exists('background_color', $input)) { $background_color = sanitize_hex_color(wp_unslash($input['background_color'])); $settings['background_color'] = $background_color ? $background_color : $this->defaults()['background_color']; } if (array_key_exists('background_image', $input)) { $settings['background_image'] = esc_url_raw(wp_unslash($input['background_image'])); } if (array_key_exists('block_xmlrpc', $input)) { $settings['block_xmlrpc'] = ! empty($input['block_xmlrpc']); } if (array_key_exists('users', $input)) { $users = array(); } if (isset($input['users']) && is_array($input['users'])) { foreach ($input['users'] as $user_id => $icon_key) { $user_id = absint($user_id); $icon_key = sanitize_key(wp_unslash($icon_key)); if ($user_id > 0 && in_array($icon_key, $icons, true)) { $users[$user_id] = $icon_key; } } } $settings['users'] = $users; foreach (array('safelist_ips', 'safelist_usernames', 'denylist_ips', 'denylist_usernames') as $field) { if (array_key_exists($field, $input)) { $settings[$field] = $this->sanitize_multiline_setting($input[$field]); } } foreach (array('ip_lockout_threshold', 'ip_lockout_window', 'ip_lockout_duration', 'ip_email_threshold', 'ip_email_window') as $field) { if (array_key_exists($field, $input)) { $settings[$field] = max(1, absint($input[$field])); } } return wp_parse_args($settings, $this->defaults()); } public function filter_xmlrpc_enabled($enabled) { $settings = $this->get_settings(); return empty($settings['block_xmlrpc']) ? $enabled : false; } public function maybe_block_xmlrpc() { if (! defined('XMLRPC_REQUEST') || ! XMLRPC_REQUEST) { return; } $settings = $this->get_settings(); if (empty($settings['block_xmlrpc'])) { return; } status_header(404); nocache_headers(); exit; } private function sanitize_multiline_setting($value) { $value = is_scalar($value) ? (string) wp_unslash($value) : ''; $lines = preg_split('/\r\n|\r|\n/', $value); $lines = array_map('sanitize_text_field', $lines); $lines = array_filter( $lines, static function ($line) { return '' !== $line; } ); return implode("\n", $lines); } public function record_failed_login($username = '', $error = null) { $this->record_login_event(self::FAILED_LOGINS_OPTION); $this->record_failed_login_detail($username, $error); } public function record_successful_login() { $this->record_login_event(self::SUCCESSFUL_LOGINS_OPTION); } private function record_login_event($option_name) { $events = $this->get_login_events($option_name, 7 * DAY_IN_SECONDS); $events[] = time(); update_option($option_name, $events, false); } public function get_client_ip() { $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : ''; return rest_is_ip_address($ip) ? $ip : ''; } public function is_ip_safelisted($ip) { $settings = $this->get_settings(); return $this->ip_matches_list($ip, $settings['safelist_ips']); } public function is_ip_denylisted($ip) { $settings = $this->get_settings(); return $this->ip_matches_list($ip, $settings['denylist_ips']); } private function ip_matches_list($ip, $list) { if ('' === $ip || '' === trim((string) $list)) { return false; } $ip_number = inet_pton($ip); if (false === $ip_number) { return false; } $entries = preg_split('/\r\n|\r|\n/', (string) $list); foreach ($entries as $entry) { $entry = trim($entry); if ('' === $entry) { continue; } if ($ip === $entry) { return true; } if (false !== strpos($entry, '-')) { $range = array_map('trim', explode('-', $entry, 2)); if (2 === count($range) && $this->ip_in_range($ip, $range[0], $range[1])) { return true; } } } return false; } private function ip_in_range($ip, $start, $end) { $ip_bin = inet_pton($ip); $start_bin = inet_pton($start); $end_bin = inet_pton($end); if (false === $ip_bin || false === $start_bin || false === $end_bin || strlen($ip_bin) !== strlen($start_bin) || strlen($ip_bin) !== strlen($end_bin)) { return false; } return strcmp($ip_bin, $start_bin) >= 0 && strcmp($ip_bin, $end_bin) <= 0; } private function record_failed_login_detail($username, $error) { $ip = $this->get_client_ip(); if ('' === $ip) { return; } $now = time(); $reason = $error instanceof WP_Error ? sanitize_key($error->get_error_code()) : 'login_failed'; $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : ''; $details = $this->get_failed_login_details(); $details[] = array( 'time' => $now, 'ip' => $ip, 'username' => substr(sanitize_text_field((string) $username), 0, 100), 'user_agent' => substr($user_agent, 0, 255), 'reason' => $reason, ); $details = array_slice($details, -500); update_option(self::FAILED_LOGIN_DETAILS_OPTION, $details, false); if (! in_array($reason, array('a360_login_avatar_ip_blocked', 'a360_login_avatar_ip_denylisted'), true)) { $this->maybe_block_ip_after_failed_login($ip, $now); $this->maybe_email_admin_about_ip($ip, $now); } } public function get_failed_login_details($retention = null) { $details = get_option(self::FAILED_LOGIN_DETAILS_OPTION, array()); if (! is_array($details)) { return array(); } $now = time(); $retention = null === $retention ? 7 * DAY_IN_SECONDS : absint($retention); $cutoff = $now - $retention; $clean = array(); foreach ($details as $detail) { if (! is_array($detail) || empty($detail['time']) || empty($detail['ip'])) { continue; } $time = absint($detail['time']); if ($time < $cutoff || $time > $now + HOUR_IN_SECONDS) { continue; } $clean[] = array( 'time' => $time, 'ip' => rest_is_ip_address($detail['ip']) ? $detail['ip'] : '', 'username' => isset($detail['username']) ? sanitize_text_field($detail['username']) : '', 'user_agent' => isset($detail['user_agent']) ? sanitize_text_field($detail['user_agent']) : '', 'reason' => isset($detail['reason']) ? sanitize_key($detail['reason']) : 'login_failed', ); } if (count($clean) !== count($details)) { update_option(self::FAILED_LOGIN_DETAILS_OPTION, $clean, false); } return $clean; } public function count_recent_failed_logins_for_ip($ip, $window_seconds) { $cutoff = time() - absint($window_seconds); $count = 0; foreach ($this->get_failed_login_details() as $detail) { if ($ip === $detail['ip'] && $detail['time'] >= $cutoff) { $count++; } } return $count; } private function maybe_block_ip_after_failed_login($ip, $now) { if ($this->is_ip_safelisted($ip)) { return; } $settings = $this->get_settings(); $count = $this->count_recent_failed_logins_for_ip($ip, absint($settings['ip_lockout_window']) * MINUTE_IN_SECONDS); if ($count < absint($settings['ip_lockout_threshold'])) { return; } $this->block_ip($ip, absint($settings['ip_lockout_duration']) * MINUTE_IN_SECONDS, $count, $now); } public function block_ip($ip, $duration, $attempts = 0, $created_at = null) { if ('' === $ip || $this->is_ip_safelisted($ip)) { return; } $created_at = null === $created_at ? time() : absint($created_at); $blocked_ips = $this->get_blocked_ips(); $blocked_ips[$this->get_ip_storage_key($ip)] = array( 'ip' => $ip, 'blocked_until' => $created_at + absint($duration), 'created_at' => $created_at, 'attempts' => absint($attempts), ); update_option(self::BLOCKED_IPS_OPTION, $blocked_ips, false); } public function get_blocked_ip($ip) { if ('' === $ip) { return null; } $blocked_ips = $this->get_blocked_ips(); $key = $this->get_ip_storage_key($ip); return isset($blocked_ips[$key]) ? $blocked_ips[$key] : null; } public function get_blocked_ips() { $blocked_ips = get_option(self::BLOCKED_IPS_OPTION, array()); if (! is_array($blocked_ips)) { return array(); } $now = time(); $clean = array(); foreach ($blocked_ips as $key => $record) { if (! is_array($record) || empty($record['ip']) || empty($record['blocked_until'])) { continue; } $ip = rest_is_ip_address($record['ip']) ? $record['ip'] : ''; $blocked_until = absint($record['blocked_until']); if ('' === $ip || $blocked_until <= $now) { continue; } $clean[$this->get_ip_storage_key($ip)] = array( 'ip' => $ip, 'blocked_until' => $blocked_until, 'created_at' => isset($record['created_at']) ? absint($record['created_at']) : $now, 'attempts' => isset($record['attempts']) ? absint($record['attempts']) : 0, ); } if (count($clean) !== count($blocked_ips)) { update_option(self::BLOCKED_IPS_OPTION, $clean, false); } return $clean; } public function is_ip_blocked($ip) { if ('' === $ip || $this->is_ip_safelisted($ip)) { return false; } if ($this->is_ip_denylisted($ip)) { return true; } return null !== $this->get_blocked_ip($ip); } private function get_ip_storage_key($ip) { return hash('sha256', $ip); } private function maybe_email_admin_about_ip($ip, $now) { $settings = $this->get_settings(); $window = absint($settings['ip_email_window']) * MINUTE_IN_SECONDS; $count = $this->count_recent_failed_logins_for_ip($ip, $window); if ($count < absint($settings['ip_email_threshold'])) { return; } $alerts = get_option('a360_login_avatar_panel_ip_alerts', array()); if (! is_array($alerts)) { $alerts = array(); } $key = $this->get_ip_storage_key($ip); $last_alert = isset($alerts[$key]) ? absint($alerts[$key]) : 0; if ($last_alert > $now - $window) { return; } $alerts[$key] = $now; update_option('a360_login_avatar_panel_ip_alerts', $alerts, false); $subject = sprintf( /* translators: %s: IP address. */ __('A360 Login Avatar Panel alert for %s', 'a360-login-avatar-panel'), $ip ); $message = sprintf( /* translators: 1: IP address, 2: failed attempt count, 3: time window in minutes, 4: site URL. */ __('IP address %1$s has %2$d failed login attempts in the last %3$d minutes on %4$s.', 'a360-login-avatar-panel'), $ip, $count, absint($settings['ip_email_window']), home_url() ); wp_mail(get_option('admin_email'), $subject, $message); } public function get_failed_login_events($retention = null) { return $this->get_login_events(self::FAILED_LOGINS_OPTION, $retention); } public function get_successful_login_events($retention = null) { return $this->get_login_events(self::SUCCESSFUL_LOGINS_OPTION, $retention); } private function get_login_events($option_name, $retention = null) { $events = get_option($option_name, array()); if (! is_array($events)) { return array(); } $original_count = count($events); $now = time(); $retention = null === $retention ? 7 * DAY_IN_SECONDS : absint($retention); $cutoff = $now - $retention; $events = array_values( array_filter( array_map('absint', $events), static function ($timestamp) use ($cutoff, $now) { return $timestamp >= $cutoff && $timestamp <= $now + HOUR_IN_SECONDS; } ) ); if (count($events) !== $original_count) { update_option($option_name, $events, false); } return $events; } public function count_failed_logins($period) { return $this->count_login_events($this->get_failed_login_events(), $period); } public function count_successful_logins($period) { return $this->count_login_events($this->get_successful_login_events(), $period); } public function reset_lockout_counter() { update_option(self::LOCKOUT_RESET_OPTION, time(), false); } public function get_lockout_counter_reset_at() { return absint(get_option(self::LOCKOUT_RESET_OPTION, 0)); } public function count_failed_logins_since_reset() { $reset_at = $this->get_lockout_counter_reset_at(); return count( array_filter( $this->get_failed_login_events(), static function ($timestamp) use ($reset_at) { return $timestamp >= $reset_at; } ) ); } private function count_login_events($events, $period) { $cutoff = time() - absint($period); return count( array_filter( $events, static function ($timestamp) use ($cutoff) { return $timestamp >= $cutoff; } ) ); } } function a360_login_avatar_panel_sanitize_settings($input) { return A360_Login_Avatar_Panel::instance()->sanitize_settings($input); } A360_Login_Avatar_Panel::instance()->init();