diff --git a/src/Bootstrap.php b/src/Bootstrap.php
index 647dbfc..5d34bbd 100644
--- a/src/Bootstrap.php
+++ b/src/Bootstrap.php
@@ -4,12 +4,12 @@
namespace Yard\Warden;
-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\Login\LoginServiceProvider;
+use Yard\Warden\Login\LostPasswordServiceProvider;
+use Yard\Warden\Onboarding\OnboardingServiceProvider;
use Yard\Warden\Password\PasswordServiceProvider;
use Yard\Warden\Password\PasswordUIServiceProvider;
use Yard\Warden\Password\StrengthValidator;
diff --git a/src/LimitLogin/LimitLoginAdminPage.php b/src/LimitLogin/LimitLoginAdminPage.php
index 6371e16..c40b5d7 100644
--- a/src/LimitLogin/LimitLoginAdminPage.php
+++ b/src/LimitLogin/LimitLoginAdminPage.php
@@ -12,77 +12,77 @@
*/
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);
- }
-
- ?>
+ 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);
+ }
+
+ ?>
@@ -93,14 +93,14 @@ public function renderPage(): void
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,
- ];
- }
+ public const FILTER_ENABLED = 'yard::warden/limit-login/enabled';
+ public const FILTER_CLIENT_IP = 'yard::warden/limit-login/client-ip';
+ public const FILTER_ERROR_MESSAGE = 'yard::warden/limit-login/error-message';
+ public const FILTER_SKIP_ERROR_CODES = 'yard::warden/limit-login/skip-error-codes';
+
+ public const ERROR_CODE = 'too_many_attempts';
+
+ /** @var string[] */
+ private const DEFAULT_SKIP_CODES = ['expired_session'];
+
+ /** @var Limiter */
+ private $limiter;
+
+ public function __construct(Limiter $limiter)
+ {
+ $this->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,
+ 'ip' === $dimension ? $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,
+ 'username' === $dimension ? $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
index e369141..dfc6bbc 100644
--- a/src/LimitLogin/Limiter.php
+++ b/src/LimitLogin/Limiter.php
@@ -51,7 +51,7 @@ public function clear(string $dimension, string $identifier): void
delete_transient($this->lockKey($dimension, $identifier));
}
-public function setLockout(string $dimension, string $identifier, int $durationSeconds): void
+ public function setLockout(string $dimension, string $identifier, int $durationSeconds): void
{
set_transient(
$this->lockKey($dimension, $identifier),
@@ -91,5 +91,4 @@ protected function lockKey(string $dimension, string $identifier): string
{
return self::LOCK_PREFIX . $dimension . ':' . hash_hmac('sha256', $identifier, wp_salt('auth'));
}
-
}
diff --git a/yard-warden.php b/yard-warden.php
index c700faf..54973cc 100644
--- a/yard-warden.php
+++ b/yard-warden.php
@@ -7,7 +7,7 @@
*
* @author Yard | Digital Agency
*
- * Plugin Name: Yard Warden
+ * Plugin Name: Yard | Warden
* Description: Enhanced password and login security for WordPress.
* Version: 1.0.2
* Author: Yard | Digital Agency