diff --git a/README.md b/README.md
index 81c8287..a04e762 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ Enhanced password and login security for WordPress.
- **Minimum length** enforcement (default 16) on top of the zxcvbn score.
- **Generic login errors** to prevent username enumeration on the wp-login form. Always on — no toggle. Only applies to authentication failures; password-reset and profile-update validation errors remain verbose so users can correct their input.
- **Safer multisite onboarding.** Auto-activates new signups server-side so no activation link is emailed and the `wp-activate.php` landing page (which would print username + plaintext password) is never reached. The welcome email is rewritten to contain a one-time password-reset link instead of a generated password.
-- **Rate limiting**
+- **Login limiting** via transient-based counters across three dimensions (IP+Username, IP, Username). Locks out brute-force attempts. Admin can clear all counters via Settings > Yard Warden.
## Hooks
diff --git a/languages/yard_warden.pot b/languages/yard_warden.pot
index e762096..8a7d2bd 100644
--- a/languages/yard_warden.pot
+++ b/languages/yard_warden.pot
@@ -32,25 +32,25 @@ msgstr ""
msgid "If an account with this username or email address exists, you will receive an email with a link to reset your password."
msgstr ""
-# Rate limiting
-#: src/RateLimit/RateLimitServiceProvider.php
+# Login limiting
+#: src/LimitLogin/LimitLoginServiceProvider.php
msgid "Too many failed login attempts. Please try again later."
msgstr ""
-#: src/RateLimit/RateLimitAdminPage.php
-msgid "Rate Limiting"
+#: src/LimitLogin/LimitLoginAdminPage.php
+msgid "Login Limiting"
msgstr ""
-#: src/RateLimit/RateLimitAdminPage.php
-msgid "If a legitimate user has been locked out due to too many failed login attempts, you can clear all rate limits below."
+#: src/LimitLogin/LimitLoginAdminPage.php
+msgid "If a legitimate user has been locked out due to too many failed login attempts, you can clear all login limits below."
msgstr ""
-#: src/RateLimit/RateLimitAdminPage.php
-msgid "Clear All Rate Limits"
+#: src/LimitLogin/LimitLoginAdminPage.php
+msgid "Clear All Login Limits"
msgstr ""
-#: src/RateLimit/RateLimitAdminPage.php
-msgid "All rate limits have been cleared."
+#: src/LimitLogin/LimitLoginAdminPage.php
+msgid "All login limits have been cleared."
msgstr ""
#. translators: %d: minimum password length
diff --git a/src/Bootstrap.php b/src/Bootstrap.php
index bbd90a8..647dbfc 100644
--- a/src/Bootstrap.php
+++ b/src/Bootstrap.php
@@ -7,6 +7,9 @@
use Yard\Warden\Login\LoginServiceProvider;
use Yard\Warden\Login\LostPasswordServiceProvider;
use Yard\Warden\Onboarding\OnboardingServiceProvider;
+use Yard\Warden\LimitLogin\Limiter;
+use Yard\Warden\LimitLogin\LimitLoginAdminPage;
+use Yard\Warden\LimitLogin\LimitLoginServiceProvider;
use Yard\Warden\Password\PasswordServiceProvider;
use Yard\Warden\Password\PasswordUIServiceProvider;
use Yard\Warden\Password\StrengthValidator;
@@ -50,8 +53,12 @@ private static function registerPasswordProviders(): void
private static function registerLoginProviders(): void
{
+ $limiter = new Limiter();
+
(new LoginServiceProvider())->register();
(new LostPasswordServiceProvider())->register();
+ (new LimitLoginServiceProvider($limiter))->register();
+ (new LimitLoginAdminPage($limiter))->register();
}
private static function registerOnboardingProviders(): void
diff --git a/src/LimitLogin/LimitLoginAdminPage.php b/src/LimitLogin/LimitLoginAdminPage.php
new file mode 100644
index 0000000..6371e16
--- /dev/null
+++ b/src/LimitLogin/LimitLoginAdminPage.php
@@ -0,0 +1,106 @@
+ Yard Warden
+ * with a button to clear all login-limit transients.
+ */
+class LimitLoginAdminPage
+{
+ private const NONCE_ACTION = 'yard_warden_clear_limit_login';
+ private const MENU_SLUG = 'yard-warden';
+
+ /** @var Limiter */
+ private $limiter;
+
+ public function __construct(Limiter $limiter)
+ {
+ $this->limiter = $limiter;
+ }
+
+ public function register(): void
+ {
+ add_action('admin_menu', [$this, 'registerPage']);
+ add_action('admin_init', [$this, 'handleClearRequest']);
+ }
+
+ public function registerPage(): void
+ {
+ add_options_page(
+ __('Yard Warden', YARD_WARDEN_TEXT_DOMAIN),
+ __('Yard Warden', YARD_WARDEN_TEXT_DOMAIN),
+ 'manage_options',
+ self::MENU_SLUG,
+ [$this, 'renderPage']
+ );
+ }
+
+ public function handleClearRequest(): void
+ {
+ if (! isset($_POST['yard_warden_clear_limit_login'])) {
+ return;
+ }
+
+ if (! current_user_can('manage_options')) {
+ return;
+ }
+
+ check_admin_referer(self::NONCE_ACTION);
+
+ $deleted = $this->limiter->clearAll();
+
+ Log::warning(sprintf(
+ 'Yard Warden: admin cleared all login limits (%d transient rows removed)',
+ $deleted
+ ));
+
+ add_settings_error(
+ self::MENU_SLUG,
+ 'limits_cleared',
+ __('All login limits have been cleared.', YARD_WARDEN_TEXT_DOMAIN),
+ 'success'
+ );
+
+ set_transient('settings_errors', get_settings_errors(), 30);
+
+ wp_safe_redirect(add_query_arg('settings-updated', 'true', menu_page_url(self::MENU_SLUG, false)));
+ exit;
+ }
+
+ public function renderPage(): void
+ {
+ if (! current_user_can('manage_options')) {
+ return;
+ }
+
+ if (isset($_GET['settings-updated'])) {
+ settings_errors(self::MENU_SLUG);
+ }
+
+ ?>
+
+ limiter = $limiter;
+ }
+
+ public function register(): void
+ {
+ // Priority 30: after core auth at 20, so a valid-credential WP_User gets
+ // overwritten with our lockout error. Same priority as LoginServiceProvider
+ // but registered after it — LoginServiceProvider only rewrites leaky codes,
+ // not our too_many_attempts code, so our response is the final value.
+ add_filter('authenticate', [$this, 'checkLockout'], 30, 2);
+ add_action('wp_login_failed', [$this, 'recordFailedAttempt'], 10, 2);
+ add_action('wp_login', [$this, 'onSuccessfulLogin'], 10, 1);
+ }
+
+ /**
+ * Block authentication if any dimension is locked out.
+ *
+ * @param WP_Error|WP_User|null $user
+ * @param string $username
+ * @return WP_Error|WP_User|null
+ */
+ public function checkLockout($user, $username)
+ {
+ if (! $this->isEnabled()) {
+ return $user;
+ }
+
+ if ('' === $username) {
+ return $user;
+ }
+
+ $username = $this->sanitizeUsername($username);
+
+ if ('' === $username) {
+ return $user;
+ }
+
+ $ip = $this->clientIp();
+ $dimensions = $this->lockoutDimensions($ip, $username);
+
+ foreach ($dimensions as $dimension => $identifier) {
+ if ($this->limiter->isLockedOut($dimension, $identifier)) {
+ Log::debug(sprintf(
+ 'Yard Warden: login blocked — %s lockout active for %s',
+ $dimension,
+ $dimension === 'ip' ? $ip : $username
+ ));
+
+ if (defined('XMLRPC_REQUEST') && XMLRPC_REQUEST && ! headers_sent()) {
+ header('HTTP/1.1 429 Too Many Requests');
+ header('Retry-After: ' . $this->lockoutDuration($dimension, $ip, $username));
+ }
+
+ return new WP_Error(self::ERROR_CODE, $this->errorMessage());
+ }
+ }
+
+ return $user;
+ }
+
+ /**
+ * Record a failed login attempt and trigger lockout if thresholds exceeded.
+ *
+ * @param string $username
+ * @param WP_Error $error
+ */
+ public function recordFailedAttempt($username, $error = null): void
+ {
+ if (! $this->isEnabled()) {
+ return;
+ }
+
+ if ($error instanceof WP_Error && $this->shouldSkipError($error)) {
+ return;
+ }
+
+ $ip = $this->clientIp();
+ $username = $this->sanitizeUsername($username);
+
+ if ('' === $ip || '' === $username) {
+ return;
+ }
+
+ $dimensions = [
+ 'ip_user' => [
+ 'identifier' => $ip . '|' . $username,
+ 'threshold' => $this->threshold('ip_user', $ip, $username),
+ 'window' => $this->window('ip_user', $ip, $username),
+ 'lockout' => $this->lockoutDuration('ip_user', $ip, $username),
+ ],
+ 'ip' => [
+ 'identifier' => $ip,
+ 'threshold' => $this->threshold('ip', $ip, $username),
+ 'window' => $this->window('ip', $ip, $username),
+ 'lockout' => $this->lockoutDuration('ip', $ip, $username),
+ ],
+ 'username' => [
+ 'identifier' => $username,
+ 'threshold' => $this->threshold('username', $ip, $username),
+ 'window' => $this->window('username', $ip, $username),
+ 'lockout' => $this->lockoutDuration('username', $ip, $username),
+ ],
+ ];
+
+ foreach ($dimensions as $dimension => $config) {
+ $count = $this->limiter->increment($dimension, $config['identifier'], $config['window']);
+
+ if ($count >= $config['threshold']) {
+ $this->limiter->setLockout($dimension, $config['identifier'], $config['lockout']);
+
+ Log::warning(sprintf(
+ 'Yard Warden: login lockout triggered for %s — %s (locked for %ds)',
+ $dimension,
+ $dimension === 'username' ? $username : $ip,
+ $config['lockout']
+ ));
+ }
+ }
+
+ Log::debug(sprintf(
+ 'Yard Warden: login attempt recorded for %s from %s',
+ $username,
+ $ip
+ ));
+ }
+
+ /**
+ * Clear all counters on successful login.
+ *
+ * @param string $username
+ */
+ public function onSuccessfulLogin($username): void
+ {
+ if (! $this->isEnabled()) {
+ return;
+ }
+
+ $ip = $this->clientIp();
+ $username = $this->sanitizeUsername($username);
+
+ if ('' === $ip || '' === $username) {
+ return;
+ }
+
+ $this->limiter->clear('ip_user', $ip . '|' . $username);
+ $this->limiter->clear('ip', $ip);
+ $this->limiter->clear('username', $username);
+
+ Log::debug(sprintf(
+ 'Yard Warden: login success, cleared ip_user, ip, and username counters for %s from %s',
+ $username,
+ $ip
+ ));
+ }
+
+ // ------------------------------------------------------------------
+ // Filters
+ // ------------------------------------------------------------------
+
+ private function isEnabled(): bool
+ {
+ return (bool) apply_filters(self::FILTER_ENABLED, true);
+ }
+
+ private function clientIp(): string
+ {
+ $ip = isset($_SERVER['REMOTE_ADDR'])
+ ? filter_var($_SERVER['REMOTE_ADDR'], FILTER_VALIDATE_IP, ['options' => ['default' => '']])
+ : '';
+
+ return (string) apply_filters(self::FILTER_CLIENT_IP, $ip);
+ }
+
+ private function errorMessage(): string
+ {
+ return (string) apply_filters(
+ self::FILTER_ERROR_MESSAGE,
+ __('Too many failed login attempts. Please try again later.', YARD_WARDEN_TEXT_DOMAIN)
+ );
+ }
+
+ private function shouldSkipError(WP_Error $error): bool
+ {
+ $skipCodes = (array) apply_filters(self::FILTER_SKIP_ERROR_CODES, self::DEFAULT_SKIP_CODES);
+
+ return in_array($error->get_error_code(), $skipCodes, true);
+ }
+
+ private function threshold(string $dimension, string $ip, string $username): int
+ {
+ $defaults = [
+ 'ip_user' => Limiter::DEFAULT_THRESHOLD_IP_USER,
+ 'ip' => Limiter::DEFAULT_THRESHOLD_IP,
+ 'username' => Limiter::DEFAULT_THRESHOLD_USERNAME,
+ ];
+
+ return (int) apply_filters(
+ 'yard::warden/limit-login/threshold/' . $dimension,
+ $defaults[$dimension] ?? $defaults['ip_user'],
+ $ip,
+ $username
+ );
+ }
+
+ private function window(string $dimension, string $ip, string $username): int
+ {
+ $defaults = [
+ 'ip_user' => Limiter::DEFAULT_WINDOW_IP_USER,
+ 'ip' => Limiter::DEFAULT_WINDOW_IP,
+ 'username' => Limiter::DEFAULT_WINDOW_USERNAME,
+ ];
+
+ return (int) apply_filters(
+ 'yard::warden/limit-login/window/' . $dimension,
+ $defaults[$dimension] ?? $defaults['ip_user'],
+ $ip,
+ $username
+ );
+ }
+
+ private function lockoutDuration(string $dimension, string $ip, string $username): int
+ {
+ $defaults = [
+ 'ip_user' => Limiter::DEFAULT_LOCKOUT_IP_USER,
+ 'ip' => Limiter::DEFAULT_LOCKOUT_IP,
+ 'username' => Limiter::DEFAULT_LOCKOUT_USERNAME,
+ ];
+
+ return (int) apply_filters(
+ 'yard::warden/limit-login/lockout/' . $dimension,
+ $defaults[$dimension] ?? $defaults['ip_user'],
+ $ip,
+ $username
+ );
+ }
+
+ private function sanitizeUsername(string $username): string
+ {
+ if (is_email($username)) {
+ return sanitize_email($username);
+ }
+
+ return sanitize_user($username, true);
+ }
+
+ /**
+ * @return array
+ */
+ private function lockoutDimensions(string $ip, string $username): array
+ {
+ return [
+ 'ip_user' => $ip . '|' . $username,
+ 'ip' => $ip,
+ 'username' => $username,
+ ];
+ }
+}
diff --git a/src/LimitLogin/Limiter.php b/src/LimitLogin/Limiter.php
new file mode 100644
index 0000000..ec678d7
--- /dev/null
+++ b/src/LimitLogin/Limiter.php
@@ -0,0 +1,95 @@
+key($dimension, $identifier);
+ $count = (int) get_transient($key);
+ $count++;
+ set_transient($key, $count, $windowSeconds);
+
+ return $count;
+ }
+
+ public function getCount(string $dimension, string $identifier): int
+ {
+ return (int) get_transient($this->key($dimension, $identifier));
+ }
+
+ public function clear(string $dimension, string $identifier): void
+ {
+ delete_transient($this->key($dimension, $identifier));
+ delete_transient($this->lockKey($dimension, $identifier));
+ }
+
+public function setLockout(string $dimension, string $identifier, int $durationSeconds): void
+ {
+ set_transient(
+ $this->lockKey($dimension, $identifier),
+ true,
+ $durationSeconds
+ );
+ }
+
+ public function isLockedOut(string $dimension, string $identifier): bool
+ {
+ return false !== get_transient($this->lockKey($dimension, $identifier));
+ }
+
+ /**
+ * @return int Number of rows deleted.
+ */
+ public function clearAll(): int
+ {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ return (int) $wpdb->query(
+ $wpdb->prepare(
+ "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
+ $wpdb->esc_like('_transient_' . self::KEY_PREFIX) . '%',
+ $wpdb->esc_like('_transient_timeout_' . self::KEY_PREFIX) . '%'
+ )
+ );
+ }
+
+ protected function key(string $dimension, string $identifier): string
+ {
+ return self::KEY_PREFIX . $dimension . ':' . md5($identifier);
+ }
+
+ protected function lockKey(string $dimension, string $identifier): string
+ {
+ return self::LOCK_PREFIX . $dimension . ':' . md5($identifier);
+ }
+
+}
diff --git a/src/Login/LoginServiceProvider.php b/src/Login/LoginServiceProvider.php
index 0ab72f1..2990262 100644
--- a/src/Login/LoginServiceProvider.php
+++ b/src/Login/LoginServiceProvider.php
@@ -18,9 +18,6 @@ class LoginServiceProvider
public const LEAKY_ERROR_CODES_FILTER = 'yard::warden/login/leaky-error-codes';
public const GENERIC_ERROR_FILTER = 'yard::warden/login/generic-error';
- /**
- * @var string[]
- */
private const DEFAULT_LEAKY_CODES = [
'invalid_username',
'invalid_email',
diff --git a/src/Login/LostPasswordServiceProvider.php b/src/Login/LostPasswordServiceProvider.php
index 80a8967..a8d1495 100644
--- a/src/Login/LostPasswordServiceProvider.php
+++ b/src/Login/LostPasswordServiceProvider.php
@@ -39,7 +39,6 @@ public function suppressUserNotFoundLeak(WP_Error $errors, $user_data): void
return;
}
- // Let WordPress handle genuinely empty input.
if ($errors->get_error_message('empty_username')) {
return;
}
diff --git a/src/Onboarding/OnboardingServiceProvider.php b/src/Onboarding/OnboardingServiceProvider.php
index 5651e84..a8b54a1 100644
--- a/src/Onboarding/OnboardingServiceProvider.php
+++ b/src/Onboarding/OnboardingServiceProvider.php
@@ -150,7 +150,7 @@ public function filterWelcomeBody($welcome_email, $user_id, $password, $meta): s
private function welcomeEmailDisabled(): bool
{
- return ! empty($_REQUEST[self::DISABLE_WELCOME_REQUEST_KEY]);
+ return isset($_REQUEST[self::DISABLE_WELCOME_REQUEST_KEY]) && '' !== $_REQUEST[self::DISABLE_WELCOME_REQUEST_KEY];
}
private function getPasswordResetKey(WP_User $user): string