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