= $max_attempts) { $remaining_seconds = admilogu_get_lockout_time_seconds($transient_name); return new WP_Error( 'too_many_attempts', sprintf( /* translators: 1: Lockout time in seconds, 2: Formatted lockout time */ __('Too many failed login attempts. Please try again in %2$s.', 'admin-login-guard-branding'), $remaining_seconds, admilogu_format_duration($remaining_seconds) ) ); } return $user; } // Run early (5) to block before password checks, and late (99) to catch any bypasses. add_filter('authenticate', 'admilogu_check_login_attempts', 5, 3); add_filter('authenticate', 'admilogu_check_login_attempts', 99, 3); /** * Increment failed attempts on login failure * * Triggered when a login fails. It increments the failed attempt count for the IP address * stored in a transient. It also logs the failure (time, IP, username, user agent) * to the custom database table. * * @param string $username The username that failed to login. */ function admilogu_login_failed($username) { $ip = admilogu_get_ip(); // Do not log if permanently blacklisted if (admilogu_is_ip_blacklisted($ip)) { return; } $transient_name = 'admilogu_attempt_' . md5($ip); $attempts = (int) get_transient($transient_name); $max_attempts = (int) get_option('admilogu_max_attempts', 3); // If the IP is already locked out, do not log further attempts to the history table if ($attempts >= $max_attempts && !admilogu_is_ip_whitelisted($ip)) { return; } // Only increment attempts for non-whitelisted IPs. if (!admilogu_is_ip_whitelisted($ip)) { if ($attempts < $max_attempts) { $attempts++; $lockout_duration = (int) get_option('admilogu_lockout_duration', 30); set_transient($transient_name, $attempts, $lockout_duration * MINUTE_IN_SECONDS); if ($attempts >= $max_attempts) { update_option($transient_name . '_expiry', time() + ($lockout_duration * MINUTE_IN_SECONDS)); // Send email notification if enabled if (get_option('admilogu_notify_admin_lockout', 0)) { $admin_email = get_option('admin_email'); /* translators: 1: Blog name */ $subject = sprintf(__('[%1$s] Lockout Alert: Too many failed login attempts', 'admin-login-guard-branding'), wp_specialchars_decode(get_option('blogname'), ENT_QUOTES)); /* translators: 1: IP address, 2: Max attempts */ $message = sprintf(__('An IP address (%1$s) has been locked out after %2$d failed login attempts.', 'admin-login-guard-branding'), $ip, $max_attempts); wp_mail($admin_email, $subject, $message); } } } } // Log to database global $wpdb; $table_name = $wpdb->prefix . 'admilogu_login_history'; // Capture username more robustly. Prioritize strings from hook, then POST/REQUEST. $log_username = ''; if (!empty($username) && is_string($username)) { $log_username = sanitize_text_field(wp_unslash($username)); // phpcs:ignore WordPress.Security.NonceVerification.Missing } elseif (!empty($_POST['log'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing $log_username = sanitize_text_field(wp_unslash($_POST['log'])); // phpcs:ignore WordPress.Security.NonceVerification.Missing } elseif (!empty($_POST['user_login'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing $log_username = sanitize_text_field(wp_unslash($_POST['user_login'])); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended } elseif (!empty($_REQUEST['log'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended $log_username = sanitize_text_field(wp_unslash($_REQUEST['log'])); } // Final fallback if (empty($log_username) || is_wp_error($log_username)) { $log_username = '(empty)'; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->insert( $table_name, array( 'ip_address' => $ip, 'username' => $log_username, 'status' => 'failed', 'time' => current_time('mysql'), 'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '' ) ); } add_action('wp_login_failed', 'admilogu_login_failed'); /** * Clear failed attempts on successful login * * Resets the failed login attempt counter for the user's IP address * upon a successful login. * * @param string $user_login The username. * @param WP_User $user The logged-in user object. */ function admilogu_login_success($user_login, $user) { $ip = admilogu_get_ip(); $transient_name = 'admilogu_attempt_' . md5($ip); delete_transient($transient_name); delete_option($transient_name . '_expiry'); // Log to database global $wpdb; $table_name = $wpdb->prefix . 'admilogu_login_history'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->insert( $table_name, array( 'ip_address' => $ip, 'username' => $user_login, 'status' => 'success', 'time' => current_time('mysql'), 'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '' ) ); // Notify user of login from new IP if enabled if (get_option('admilogu_notify_user_new_ip', 0)) { $known_ips = get_user_meta($user->ID, 'admilogu_known_ips', true); if (!is_array($known_ips)) { $known_ips = array(); } if (!in_array($ip, $known_ips, true)) { // It's a new IP! Send email. /* translators: 1: Blog name */ $subject = sprintf(__('[%1$s] Notice of Login from New Device/IP', 'admin-login-guard-branding'), wp_specialchars_decode(get_option('blogname'), ENT_QUOTES)); /* translators: 1: User display name, 2: IP address */ $message = sprintf(__('Hello %1$s, We noticed a successful login to your account from a new IP address: %2$s. If this was you, you can safely ignore this email. If you did not authorize this login, please reset your password immediately.', 'admin-login-guard-branding'), $user->display_name, $ip); wp_mail($user->user_email, $subject, $message); // Add to known IPs $known_ips[] = $ip; update_user_meta($user->ID, 'admilogu_known_ips', $known_ips); } } } add_action('wp_login', 'admilogu_login_success', 10, 2); /** * Show attempts remaining in login error message * * Appends a message to the login error indicating how many attempts are remaining * before the user is locked out. * * @param WP_Error $errors The current WP_Error object containing login errors. * @return WP_Error The modified error object with the attempts message added. */ function admilogu_show_attempt_message($errors) { // Only modify if there is an error if (!is_wp_error($errors)) { return $errors; } // Check if it's an invalid username or incorrect password error $error_codes = $errors->get_error_codes(); if (empty($error_codes) || (!in_array('invalid_username', $error_codes) && !in_array('incorrect_password', $error_codes) && !in_array('invalid_email', $error_codes))) { return $errors; } $max_attempts = (int) get_option('admilogu_max_attempts', 3); $ip = admilogu_get_ip(); // Hide for whitelisted IPs if (admilogu_is_ip_whitelisted($ip)) { return $errors; } $transient_name = 'admilogu_attempt_' . md5($ip); $attempts = (int) get_transient($transient_name); $remaining = $max_attempts - $attempts; if ($remaining <= 0) { $remaining_seconds = admilogu_get_lockout_time_seconds($transient_name); $message = sprintf( /* translators: 1: Lockout time in seconds, 2: Formatted lockout time */ __('No attempts remaining. You are now locked out for %2$s.', 'admin-login-guard-branding'), $remaining_seconds, admilogu_format_duration($remaining_seconds) ); } else { /* translators: %d: number of attempts remaining */ $message = sprintf(_n('%d attempt remaining.', '%d attempts remaining.', $remaining, 'admin-login-guard-branding'), $remaining); } $errors->add('attempts_remaining', '' . $message . ''); return $errors; } add_filter('wp_login_errors', 'admilogu_show_attempt_message'); /** * Get the remaining lockout time in minutes for a given transient. * * Checks the dedicated expiry option first, then falls back to the * transient timeout, and finally returns the configured lockout * duration if neither is available. * * @since 1.0.0 * * @param string $transient_name The base name of the lockout transient. * @return int Minutes remaining until lockout expires. */ function admilogu_get_lockout_time_remaining($transient_name) { // Check our dedicated expiry option first. $expiry = get_option($transient_name . '_expiry'); if ($expiry) { $remaining = (int) $expiry - time(); return $remaining > 0 ? ceil($remaining / 60) : 0; } // Fallback to transient timeout for backward compatibility. $timeout = get_option('_transient_timeout_' . $transient_name); if ($timeout) { $remaining = (int) $timeout - time(); return $remaining > 0 ? ceil($remaining / 60) : 0; } $lockout_duration = (int) get_option('admilogu_lockout_duration', 30); return $lockout_duration; } /** * Get the remaining lockout time in seconds for a given transient. * * @param string $transient_name The name of the lockout transient. * @return int Seconds remaining until expiration. */ function admilogu_get_lockout_time_seconds($transient_name) { // Check our dedicated expiry option. $expiry = get_option($transient_name . '_expiry'); if ($expiry) { $remaining = (int) $expiry - time(); if ($remaining <= 0) { delete_option($transient_name . '_expiry'); return 0; } return $remaining; } // Fallback to transient timeout. $timeout = get_option('_transient_timeout_' . $transient_name); if ($timeout) { $remaining = (int) $timeout - time(); return $remaining > 0 ? $remaining : 0; } $lockout_duration = (int) get_option('admilogu_lockout_duration', 30); return $lockout_duration * 60; } /** * Format duration in seconds to MM:SS or human readable minutes. * * @param int $seconds Total seconds. * @return string Formatted duration. */ function admilogu_format_duration($seconds) { if ($seconds >= 60) { $minutes = floor($seconds / 60); $secs = $seconds % 60; return sprintf('%d:%02d', $minutes, $secs); } /* translators: %d: number of seconds */ return sprintf(__('%d seconds', 'admin-login-guard-branding'), $seconds); } /** * Check if an IP address is whitelisted. * * @param string $ip The IP address to check. * @return bool True if whitelisted, false otherwise. */ function admilogu_is_ip_whitelisted($ip) { $whitelist_str = get_option('admilogu_ip_whitelist', ''); if (empty($whitelist_str)) { return false; } $whitelisted_ips = array_map('trim', explode("\n", $whitelist_str)); $whitelisted_ips = array_filter($whitelisted_ips); // Remove empty lines return in_array($ip, $whitelisted_ips, true); } /** * Check if an IP address is blacklisted. * * @param string $ip The IP address to check. * @return bool True if blacklisted, false otherwise. */ function admilogu_is_ip_blacklisted($ip) { $blacklist_str = get_option('admilogu_ip_blacklist', ''); if (empty($blacklist_str)) { return false; } $blacklisted_ips = array_map('trim', explode("\n", $blacklist_str)); $blacklisted_ips = array_filter($blacklisted_ips); // Remove empty lines return in_array($ip, $blacklisted_ips, true); }