From 10b4f7bee2597fc45ef586196783f9ddb299c64a Mon Sep 17 00:00:00 2001 From: Roric van Schalkwijk Date: Thu, 16 Apr 2026 21:49:08 +0200 Subject: [PATCH 1/2] feat: limit login attempts --- README.md | 2 +- languages/yard_warden.pot | 20 +- src/Bootstrap.php | 7 + src/LimitLogin/LimitLoginAdminPage.php | 106 +++++++ src/LimitLogin/LimitLoginServiceProvider.php | 280 +++++++++++++++++++ src/LimitLogin/Limiter.php | 154 ++++++++++ src/Login/LoginServiceProvider.php | 3 +- src/Login/LostPasswordServiceProvider.php | 1 - 8 files changed, 559 insertions(+), 14 deletions(-) create mode 100644 src/LimitLogin/LimitLoginAdminPage.php create mode 100644 src/LimitLogin/LimitLoginServiceProvider.php create mode 100644 src/LimitLogin/Limiter.php 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 before it — and it only rewrites leaky codes, not ours. + 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 (empty($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: 300'); + } + + 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 + )); + } + + /** + * Decrement IP+Username and IP 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); + + Log::debug(sprintf( + 'Yard Warden: login success, cleared ip_user and ip 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..19e29ae --- /dev/null +++ b/src/LimitLogin/Limiter.php @@ -0,0 +1,154 @@ +key($dimension, $identifier); + $count = (int) get_transient($key); + $count++; + set_transient($key, $count, $windowSeconds); + + return $count; + } + + /** + * Get the current count for a dimension + identifier. + */ + public function getCount(string $dimension, string $identifier): int + { + return (int) get_transient($this->key($dimension, $identifier)); + } + + /** + * Clear the counter and any active lockout for a dimension + identifier. + */ + public function clear(string $dimension, string $identifier): void + { + delete_transient($this->key($dimension, $identifier)); + delete_transient($this->lockKey($dimension, $identifier)); + } + + /** + * Decrement the counter, flooring at zero. + */ + public function decrement(string $dimension, string $identifier): int + { + $key = $this->key($dimension, $identifier); + $count = (int) get_transient($key); + + if (1 >= $count) { + delete_transient($key); + + return 0; + } + + $count--; + + // Remaining TTL cannot be read from transients; use dimension default. + $window = $this->defaultWindow($dimension); + set_transient($key, $count, $window); + + return $count; + } + + /** + * Mark a dimension + identifier as locked out. + */ + public function setLockout(string $dimension, string $identifier, int $durationSeconds): void + { + set_transient( + $this->lockKey($dimension, $identifier), + true, + $durationSeconds + ); + } + + /** + * Check whether a dimension + identifier is currently locked out. + */ + public function isLockedOut(string $dimension, string $identifier): bool + { + return false !== get_transient($this->lockKey($dimension, $identifier)); + } + + /** + * Delete all login-limit transients from the database. + * + * @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); + } + + protected function defaultWindow(string $dimension): int + { + switch ($dimension) { + case 'ip_user': + return self::DEFAULT_WINDOW_IP_USER; + case 'ip': + return self::DEFAULT_WINDOW_IP; + case 'username': + return self::DEFAULT_WINDOW_USERNAME; + default: + return self::DEFAULT_WINDOW_IP_USER; + } + } +} diff --git a/src/Login/LoginServiceProvider.php b/src/Login/LoginServiceProvider.php index 0ab72f1..b55f1c9 100644 --- a/src/Login/LoginServiceProvider.php +++ b/src/Login/LoginServiceProvider.php @@ -34,11 +34,10 @@ public function register(): void /** * @param WP_Error|\WP_User|null $user - * @param string $username * * @return WP_Error|\WP_User|null */ - public function filterAuthenticateErrors($user, $username) + public function filterAuthenticateErrors($user) { if (! $user instanceof WP_Error) { return $user; 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; } From ab5408591c1d61c9779632ffa341a144925fc641 Mon Sep 17 00:00:00 2001 From: Roric van Schalkwijk Date: Fri, 15 May 2026 16:23:33 +0200 Subject: [PATCH 2/2] refactor: cleanup comments, usages of empty and unused methods --- src/LimitLogin/LimitLoginServiceProvider.php | 18 ++++-- src/LimitLogin/Limiter.php | 61 +------------------- src/Login/LoginServiceProvider.php | 6 +- src/Onboarding/OnboardingServiceProvider.php | 2 +- 4 files changed, 17 insertions(+), 70 deletions(-) diff --git a/src/LimitLogin/LimitLoginServiceProvider.php b/src/LimitLogin/LimitLoginServiceProvider.php index f6c2284..06ce7b5 100644 --- a/src/LimitLogin/LimitLoginServiceProvider.php +++ b/src/LimitLogin/LimitLoginServiceProvider.php @@ -36,7 +36,8 @@ 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 before it — and it only rewrites leaky codes, not ours. + // 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); @@ -55,7 +56,13 @@ public function checkLockout($user, $username) return $user; } - if (empty($username)) { + if ('' === $username) { + return $user; + } + + $username = $this->sanitizeUsername($username); + + if ('' === $username) { return $user; } @@ -72,7 +79,7 @@ public function checkLockout($user, $username) if (defined('XMLRPC_REQUEST') && XMLRPC_REQUEST && ! headers_sent()) { header('HTTP/1.1 429 Too Many Requests'); - header('Retry-After: 300'); + header('Retry-After: ' . $this->lockoutDuration($dimension, $ip, $username)); } return new WP_Error(self::ERROR_CODE, $this->errorMessage()); @@ -149,7 +156,7 @@ public function recordFailedAttempt($username, $error = null): void } /** - * Decrement IP+Username and IP counters on successful login. + * Clear all counters on successful login. * * @param string $username */ @@ -168,9 +175,10 @@ public function onSuccessfulLogin($username): void $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 and ip counters for %s from %s', + 'Yard Warden: login success, cleared ip_user, ip, and username counters for %s from %s', $username, $ip )); diff --git a/src/LimitLogin/Limiter.php b/src/LimitLogin/Limiter.php index 19e29ae..ec678d7 100644 --- a/src/LimitLogin/Limiter.php +++ b/src/LimitLogin/Limiter.php @@ -15,30 +15,21 @@ */ class Limiter { - /** @var string */ protected const KEY_PREFIX = 'yw_ll:'; - - /** @var string */ protected const LOCK_PREFIX = 'yw_ll:lock:'; - // Default thresholds (attempts before lockout). public const DEFAULT_THRESHOLD_IP_USER = 5; public const DEFAULT_THRESHOLD_IP = 50; public const DEFAULT_THRESHOLD_USERNAME = 3; - // Default windows (seconds in which attempts are counted). public const DEFAULT_WINDOW_IP_USER = 300; // 5 min public const DEFAULT_WINDOW_IP = 3600; // 60 min public const DEFAULT_WINDOW_USERNAME = 1500; // 25 min - // Default lockout durations (seconds). public const DEFAULT_LOCKOUT_IP_USER = 300; // 5 min public const DEFAULT_LOCKOUT_IP = 3600; // 60 min public const DEFAULT_LOCKOUT_USERNAME = 1500; // 25 min - /** - * Increment the counter for a given key and return the new count. - */ public function increment(string $dimension, string $identifier, int $windowSeconds): int { $key = $this->key($dimension, $identifier); @@ -49,50 +40,18 @@ public function increment(string $dimension, string $identifier, int $windowSeco return $count; } - /** - * Get the current count for a dimension + identifier. - */ public function getCount(string $dimension, string $identifier): int { return (int) get_transient($this->key($dimension, $identifier)); } - /** - * Clear the counter and any active lockout for a dimension + identifier. - */ public function clear(string $dimension, string $identifier): void { delete_transient($this->key($dimension, $identifier)); delete_transient($this->lockKey($dimension, $identifier)); } - /** - * Decrement the counter, flooring at zero. - */ - public function decrement(string $dimension, string $identifier): int - { - $key = $this->key($dimension, $identifier); - $count = (int) get_transient($key); - - if (1 >= $count) { - delete_transient($key); - - return 0; - } - - $count--; - - // Remaining TTL cannot be read from transients; use dimension default. - $window = $this->defaultWindow($dimension); - set_transient($key, $count, $window); - - return $count; - } - - /** - * Mark a dimension + identifier as locked out. - */ - 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), @@ -101,17 +60,12 @@ public function setLockout(string $dimension, string $identifier, int $durationS ); } - /** - * Check whether a dimension + identifier is currently locked out. - */ public function isLockedOut(string $dimension, string $identifier): bool { return false !== get_transient($this->lockKey($dimension, $identifier)); } /** - * Delete all login-limit transients from the database. - * * @return int Number of rows deleted. */ public function clearAll(): int @@ -138,17 +92,4 @@ protected function lockKey(string $dimension, string $identifier): string return self::LOCK_PREFIX . $dimension . ':' . md5($identifier); } - protected function defaultWindow(string $dimension): int - { - switch ($dimension) { - case 'ip_user': - return self::DEFAULT_WINDOW_IP_USER; - case 'ip': - return self::DEFAULT_WINDOW_IP; - case 'username': - return self::DEFAULT_WINDOW_USERNAME; - default: - return self::DEFAULT_WINDOW_IP_USER; - } - } } diff --git a/src/Login/LoginServiceProvider.php b/src/Login/LoginServiceProvider.php index b55f1c9..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', @@ -34,10 +31,11 @@ public function register(): void /** * @param WP_Error|\WP_User|null $user + * @param string $username * * @return WP_Error|\WP_User|null */ - public function filterAuthenticateErrors($user) + public function filterAuthenticateErrors($user, $username) { if (! $user instanceof WP_Error) { return $user; 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