= $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);
}