PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.9.5
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.9.5
3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
vigilante / includes / class-login-security.php

class-login-security.php in Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… 2.9.5, at includes/class-login-security.php

1,486 lines 50.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Login Security Class
4 *
5 * Handles login protection, brute force prevention and lockouts
6 *
7 * @package Vigilante
8 */
9
10 // Prevent direct access
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Class Vigilante_Login_Security
17 *
18 * Manages login security features
19 */
20 class Vigilante_Login_Security {
21
22 /**
23 * Settings instance
24 *
25 * @var Vigilante_Settings
26 */
27 private $settings;
28
29 /**
30 * Database instance
31 *
32 * @var Vigilante_Database
33 */
34 private $database;
35
36 /**
37 * Activity log instance
38 *
39 * @var Vigilante_Activity_Log
40 */
41 private $activity_log;
42
43 /**
44 * Login security options
45 *
46 * @var array
47 */
48 private $options;
49
50 /**
51 * Custom login slug
52 *
53 * @var string
54 */
55 private $custom_login_slug = '';
56
57 /**
58 * Whether the current login error carries a Vigilant-specific code
59 * that must bypass the generic-message mask in hide_login_errors().
60 * Set by detect_specific_login_error() (hooked to wp_login_errors).
61 *
62 * @var bool
63 */
64 private $show_specific_login_error = false;
65
66 /**
67 * Whether we are rendering the login action specifically.
68 *
69 * The login_errors filter that hide_login_errors() masks is fired by
70 * login_header() on every wp-login.php screen (login, register,
71 * lostpassword, resetpass). Only the login action also fires
72 * wp_login_errors, so detect_specific_login_error() runs solely there
73 * and flips this flag. When it stays false the generic mask is skipped,
74 * so registration / lost-password / reset-password keep their real
75 * validation errors instead of "Invalid username or password".
76 *
77 * @var bool
78 */
79 private $in_login_context = false;
80
81 /**
82 * Constructor
83 *
84 * @param Vigilante_Settings $settings Settings instance.
85 * @param Vigilante_Database $database Database instance.
86 * @param Vigilante_Activity_Log $activity_log Activity log instance.
87 */
88 public function __construct( $settings, $database, $activity_log ) {
89 $this->settings = $settings;
90 $this->database = $database;
91 $this->activity_log = $activity_log;
92 $this->options = $settings->get_section( 'login_security' );
93
94 $this->init_hooks();
95 }
96
97 /**
98 * Initialize hooks
99 */
100 private function init_hooks() {
101 // Check lockout before authentication
102 add_filter( 'authenticate', array( $this, 'check_lockout' ), 30, 3 );
103
104 // Track login attempts
105 add_action( 'wp_login_failed', array( $this, 'handle_failed_login' ) );
106 add_action( 'wp_login', array( $this, 'handle_successful_login' ), 10, 2 );
107
108 // Hide login errors
109 if ( ! empty( $this->options['hide_login_errors'] ) ) {
110 add_filter( 'wp_login_errors', array( $this, 'detect_specific_login_error' ), 10, 2 );
111 add_filter( 'login_errors', array( $this, 'hide_login_errors' ) );
112 add_filter( 'shake_error_codes', array( $this, 'remove_shake_errors' ) );
113 }
114
115 // Disable XML-RPC
116 if ( ! empty( $this->options['disable_xmlrpc'] ) ) {
117 add_filter( 'xmlrpc_enabled', '__return_false' );
118 add_filter( 'wp_xmlrpc_server_class', array( $this, 'disable_xmlrpc_server' ) );
119 remove_action( 'wp_head', 'rsd_link' );
120 remove_action( 'wp_head', 'wlwmanifest_link' );
121 }
122
123 // Disable XML-RPC pingback method specifically
124 if ( ! empty( $this->options['disable_xmlrpc_pingback'] ) ) {
125 add_filter( 'xmlrpc_methods', array( $this, 'disable_xmlrpc_pingback' ) );
126 }
127
128 // Disable application passwords
129 if ( ! empty( $this->options['disable_application_passwords'] ) ) {
130 add_filter( 'wp_is_application_passwords_available', '__return_false' );
131 }
132
133 // Notify on admin login
134 if ( ! empty( $this->options['notify_on_admin_login'] ) ) {
135 add_action( 'wp_login', array( $this, 'notify_admin_login' ), 10, 2 );
136 }
137
138 // Add lockout info to login form
139 add_action( 'login_form', array( $this, 'show_remaining_attempts' ) );
140
141 // Custom login URL
142 if ( ! empty( $this->options['custom_login_url'] ) ) {
143 $this->init_custom_login();
144 }
145 }
146
147 /**
148 * Initialize custom login URL functionality
149 *
150 * Uses request interception instead of rewrite rules for reliability
151 */
152 private function init_custom_login() {
153 $custom_url = sanitize_title( $this->options['custom_login_url'] );
154
155 if ( empty( $custom_url ) ) {
156 return;
157 }
158
159 // Store custom URL for use in other methods
160 $this->custom_login_slug = $custom_url;
161
162 // Intercept requests early - this is the key hook
163 add_action( 'wp_loaded', array( $this, 'wp_loaded_handler' ) );
164
165 // Filter login URL
166 add_filter( 'login_url', array( $this, 'filter_login_url' ), 10, 3 );
167 add_filter( 'logout_url', array( $this, 'filter_logout_url' ), 10, 2 );
168 add_filter( 'lostpassword_url', array( $this, 'filter_lostpassword_url' ), 10, 2 );
169 add_filter( 'register_url', array( $this, 'filter_register_url' ) );
170
171 // After requesting "lost password", core redirects to wp-login.php?checkemail=confirm
172 // which is blocked by block_wp_login_access (no whitelisted action) and yields a 404.
173 // Send the user to the custom login URL instead so the confirmation message renders.
174 add_filter( 'lostpassword_redirect', array( $this, 'filter_lostpassword_redirect' ) );
175
176 // Block direct wp-login.php access (always when custom URL is set)
177 add_action( 'login_init', array( $this, 'block_wp_login_access' ), 1 );
178
179 // Site URL filter for login form action
180 add_filter( 'site_url', array( $this, 'filter_site_url' ), 10, 4 );
181
182 // Redirect to home after logout instead of wp-login.php
183 add_filter( 'logout_redirect', array( $this, 'filter_logout_redirect' ), 10, 3 );
184
185 // Block wp-admin access for non-logged users - execute immediately
186 $this->block_wp_admin_access();
187
188 // Intercept redirects to wp-login.php from wp-admin and show 404 instead
189 add_filter( 'wp_redirect', array( $this, 'intercept_admin_redirect' ), 1, 2 );
190
191 // Block core's /login and /wp-login.php pretty-URL shortcuts.
192 // Priority 1: must win over redirect_canonical() (10) and
193 // wp_redirect_admin_locations() (1000), which would 302 the
194 // shortcut to wp_login_url() — the hidden URL — leaking the slug.
195 add_action( 'template_redirect', array( $this, 'block_login_shortcuts' ), 1 );
196 }
197
198 /**
199 * Intercept redirects to wp-login.php from wp-admin
200 * Shows 404 instead of redirecting to login
201 *
202 * @param string $location The redirect location.
203 * @param int $status The redirect status code.
204 * @return string
205 */
206 public function intercept_admin_redirect( $location, $status ) {
207
208 // Only intercept if custom login URL is set
209 if ( empty( $this->options['custom_login_url'] ) ) {
210 return $location;
211 }
212
213 // Check if this is a redirect to the login screen. Besides literal
214 // wp-login.php, auth_redirect() targets wp_login_url(), which the
215 // login_url filter has already rewritten to the hidden slug — so a
216 // redirect to the custom login URL must be caught too, or an
217 // anonymous POST to /wp-admin would leak the slug in the Location
218 // header (the login_url filter runs for every wp_login_url() call).
219 if ( strpos( $location, 'wp-login.php' ) === false && ! $this->is_hidden_login_url( $location ) ) {
220 return $location;
221 }
222
223 // Check if the redirect is coming from wp-admin area
224 $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
225
226
227 // If accessing wp-admin and being redirected to login, show 404 instead
228 if ( $this->is_wp_admin_request() ) {
229 // Don't intercept admin-ajax.php or admin-post.php
230 if ( $this->is_open_admin_endpoint() ) {
231 return $location;
232 }
233
234 // Allow whitelisted IPs (e.g. remote managers like MainWP/ManageWP).
235 if ( $this->is_ip_exempt_from_hiding() ) {
236 return $location;
237 }
238
239
240 // Log the attempt
241 if ( $this->activity_log ) {
242 $this->activity_log->log(
243 'login',
244 'hidden_admin_access',
245 __( 'Attempt to access hidden wp-admin', 'vigilante' ),
246 array( 'request_uri' => $request ),
247 'warning'
248 );
249 }
250
251 $this->serve_404();
252 }
253
254 return $location;
255 }
256
257 /**
258 * Whether a URL points at the hidden custom login screen.
259 *
260 * Matches the exact URL, with or without trailing slash, and with a
261 * query string. Prefix-matching the bare slug is deliberately avoided
262 * so a slug like "acceso" does not match "accesorios".
263 *
264 * @since 2.9.3
265 * @param string $url URL to test.
266 * @return bool
267 */
268 private function is_hidden_login_url( $url ) {
269 if ( empty( $this->custom_login_slug ) ) {
270 return false;
271 }
272
273 $hidden = home_url( $this->custom_login_slug . '/' );
274 $bare = untrailingslashit( $hidden );
275
276 return 0 === strpos( $url, $hidden )
277 || $url === $bare
278 || 0 === strpos( $url, $bare . '?' );
279 }
280
281 /**
282 * The request path as sent by the client, query string stripped.
283 *
284 * Unlike get_request_path(), the result is NOT made relative to
285 * home_url(): the admin area can hang from a different path than the
286 * site itself (WP_SITEURL vs WP_HOME), so wp-admin matching needs the
287 * raw path.
288 *
289 * @since 2.9.4
290 * @return string
291 */
292 private function get_request_uri_path() {
293 $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
294
295 if ( '' === $request ) {
296 return '';
297 }
298
299 if ( false !== strpos( $request, '?' ) ) {
300 $request = strstr( $request, '?', true );
301 }
302
303 return untrailingslashit( $request );
304 }
305
306 /**
307 * Whether the current request targets the real wp-admin area.
308 *
309 * Replaces a strpos() for "/wp-admin" over the whole REQUEST_URI. That
310 * test also matched the query string, so /?redirect_to=/wp-admin/ turned
311 * the home page into a 404 and an un-encoded redirect_to broke the hidden
312 * login screen itself; it matched any front-end path merely starting with
313 * those characters (/wp-admin-tips/); and it dragged scanner hits on
314 * non-existent subdirectories (/blog/wp-admin/) into the blocking path,
315 * where they were answered from 'init' instead of by WordPress' own 404.
316 *
317 * Two independent signals, ORed so neither is a single point of failure:
318 * is_admin(), set by the wp-admin bootstrap and immune to filters, and a
319 * path comparison against admin_url() for the rare setup that serves the
320 * admin directory through the front controller. Prefix matching requires
321 * a following slash, the same precaution is_hidden_login_url() takes.
322 *
323 * @since 2.9.4
324 * @return bool
325 */
326 private function is_wp_admin_request() {
327 if ( is_admin() ) {
328 return true;
329 }
330
331 $path = $this->get_request_uri_path();
332
333 if ( '' === $path ) {
334 return false;
335 }
336
337 $admin_path = wp_parse_url( admin_url(), PHP_URL_PATH );
338
339 if ( empty( $admin_path ) ) {
340 return false;
341 }
342
343 $admin_path = untrailingslashit( $admin_path );
344
345 return $path === $admin_path || 0 === strpos( $path, $admin_path . '/' );
346 }
347
348 /**
349 * Whether the request targets an admin entry point that must stay open.
350 *
351 * admin-ajax.php and admin-post.php are used by logged-out visitors on
352 * the front end (WooCommerce fragments, form handlers), so hiding
353 * wp-admin must never touch them. Matched as the exact admin path plus
354 * the file name: the old check ran over the whole REQUEST_URI, so
355 * /wp-admin/edit.php?x=admin-ajax.php slipped past the block, and a bare
356 * basename() would do the same for a PATH_INFO style request such as
357 * /wp-admin/options-general.php/admin-ajax.php. Falls back to the file
358 * name only when admin_url() gives nothing to compare against, so a
359 * broken filter can never take AJAX down for logged-out visitors.
360 *
361 * @since 2.9.4
362 * @return bool
363 */
364 private function is_open_admin_endpoint() {
365 $path = $this->get_request_uri_path();
366
367 if ( '' === $path ) {
368 return false;
369 }
370
371 $endpoints = array( 'admin-ajax.php', 'admin-post.php' );
372 $admin_path = wp_parse_url( admin_url(), PHP_URL_PATH );
373
374 if ( empty( $admin_path ) ) {
375 return in_array( basename( $path ), $endpoints, true );
376 }
377
378 $admin_path = untrailingslashit( $admin_path );
379
380 foreach ( $endpoints as $endpoint ) {
381 if ( $path === $admin_path . '/' . $endpoint ) {
382 return true;
383 }
384 }
385
386 return false;
387 }
388
389 /**
390 * Block access to wp-admin for non-logged users
391 * Shows 404 instead of redirecting to login
392 */
393 public function block_wp_admin_access() {
394 // Only if custom login URL is set
395 if ( empty( $this->options['custom_login_url'] ) ) {
396 return;
397 }
398
399 // Get the request URI
400 $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
401
402
403 // Check if accessing wp-admin
404 if ( ! $this->is_wp_admin_request() ) {
405 return;
406 }
407
408 // Allow admin-ajax.php and admin-post.php
409 if ( $this->is_open_admin_endpoint() ) {
410 return;
411 }
412
413 // Allow if user is logged in
414 if ( is_user_logged_in() ) {
415 return;
416 }
417
418 // Allow POST requests
419 $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
420 if ( 'POST' === $request_method ) {
421 return;
422 }
423
424 // Allow whitelisted IPs (e.g. remote managers like MainWP/ManageWP).
425 if ( $this->is_ip_exempt_from_hiding() ) {
426 return;
427 }
428
429
430 // Log the attempt
431 if ( $this->activity_log ) {
432 $this->activity_log->log(
433 'login',
434 'hidden_admin_access',
435 __( 'Attempt to access hidden wp-admin', 'vigilante' ),
436 array( 'request_uri' => $request ),
437 'warning'
438 );
439 }
440
441 $this->serve_404();
442 }
443
444 /**
445 * Handle requests on wp_loaded
446 * This intercepts requests to our custom login URL
447 */
448 public function wp_loaded_handler() {
449 global $pagenow;
450
451 // Get the request path
452 $request = $this->get_request_path();
453
454
455 // Check if accessing our custom login URL
456 if ( $this->is_custom_login_request( $request ) ) {
457
458 // Set flag that we're coming from custom login
459 if ( ! defined( 'VIGILANTE_CUSTOM_LOGIN' ) ) {
460 define( 'VIGILANTE_CUSTOM_LOGIN', true );
461 }
462
463 // Set pagenow to wp-login.php for compatibility
464 $pagenow = 'wp-login.php';
465
466 // Initialize global variables expected by wp-login.php (PHP 8.x strict)
467 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- Required by WordPress core wp-login.php
468 global $user_login, $error;
469 $user_login = '';
470 $error = '';
471
472 // Load the login page
473 require_once ABSPATH . 'wp-login.php';
474 exit;
475 }
476 }
477
478 /**
479 * Block direct access to wp-login.php
480 * Uses login_init hook which fires inside wp-login.php
481 */
482 public function block_wp_login_access() {
483
484 // If we came from our custom login URL, allow access
485 if ( defined( 'VIGILANTE_CUSTOM_LOGIN' ) && VIGILANTE_CUSTOM_LOGIN ) {
486 return;
487 }
488
489 // Allow POST requests (form submissions)
490 $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
491 if ( 'POST' === $request_method ) {
492 return;
493 }
494
495 // Allow AJAX requests
496 if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
497 return;
498 }
499
500 // Check for specific allowed actions that need wp-login.php
501 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
502 $action = isset( $_GET['action'] ) ? sanitize_key( $_GET['action'] ) : '';
503 $allowed_actions = array( 'postpass', 'logout', 'rp', 'resetpass', 'confirmaction', 'lostpassword', 'retrievepassword' );
504
505
506 if ( in_array( $action, $allowed_actions, true ) ) {
507 return;
508 }
509
510 // Allow informational query strings that core appends without an action,
511 // e.g. ?checkemail=confirm after a lost-password request and ?password=changed
512 // after a successful reset. These render the corresponding success message
513 // inside wp-login.php and would otherwise 404.
514 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
515 if ( isset( $_GET['checkemail'] ) || isset( $_GET['password'] ) ) {
516 return;
517 }
518
519 // Check if user already logged in - redirect to admin
520 if ( is_user_logged_in() ) {
521 wp_safe_redirect( admin_url() );
522 exit;
523 }
524
525 // Allow whitelisted IPs (e.g. remote managers like MainWP/ManageWP).
526 if ( $this->is_ip_exempt_from_hiding() ) {
527 return;
528 }
529
530
531 // Log the attempt
532 if ( $this->activity_log ) {
533 $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
534 $this->activity_log->log(
535 'login',
536 'hidden_login_access',
537 __( 'Attempt to access hidden wp-login.php', 'vigilante' ),
538 array( 'request_uri' => $request ),
539 'warning'
540 );
541 }
542
543 // Return 404
544 $this->serve_404();
545 }
546
547 /**
548 * Block WordPress' pretty-URL login shortcuts (/login, /wp-login.php).
549 *
550 * Core's wp_redirect_admin_locations() (template_redirect, priority
551 * 1000) turns a 404 on those paths into wp_redirect( wp_login_url() ).
552 * With a custom login URL active wp_login_url() IS the hidden slug, so
553 * that 302 would hand the secret to anyone typing /login, while /admin
554 * correctly ends in a 404. Runs only when the request is already a 404:
555 * if a real page named "login" exists, core does not redirect either
556 * and this must not interfere.
557 *
558 * @since 2.9.3
559 */
560 public function block_login_shortcuts() {
561 if ( ! is_404() ) {
562 return;
563 }
564
565 $request = strtolower( $this->get_request_path() );
566
567 if ( ! in_array( $request, array( 'login', 'wp-login.php' ), true ) ) {
568 return;
569 }
570
571 // Allow whitelisted IPs (e.g. remote managers like MainWP/ManageWP).
572 if ( $this->is_ip_exempt_from_hiding() ) {
573 return;
574 }
575
576 if ( $this->activity_log ) {
577 $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
578 $this->activity_log->log(
579 'login',
580 'hidden_login_access',
581 __( 'Attempt to access hidden wp-login.php', 'vigilante' ),
582 array( 'request_uri' => $request_uri ),
583 'warning'
584 );
585 }
586
587 $this->serve_404();
588 }
589
590 /**
591 * Get the request path without query string
592 *
593 * @return string
594 */
595 private function get_request_path() {
596 $request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
597
598 // Remove query string
599 if ( false !== strpos( $request, '?' ) ) {
600 $request = strstr( $request, '?', true );
601 }
602
603 // Get path relative to home URL
604 $home_path = wp_parse_url( home_url(), PHP_URL_PATH );
605 if ( ! empty( $home_path ) ) {
606 $request = str_replace( $home_path, '', $request );
607 }
608
609 // Clean up the path
610 $request = ltrim( $request, '/' );
611 $request = rtrim( $request, '/' );
612
613 return $request;
614 }
615
616 /**
617 * Check if this is a request to our custom login URL
618 *
619 * @param string $request The request path.
620 * @return bool
621 */
622 private function is_custom_login_request( $request ) {
623 return $request === $this->custom_login_slug;
624 }
625
626 /**
627 * Filter the login URL
628 *
629 * @param string $login_url The login URL.
630 * @param string $redirect The redirect URL.
631 * @param bool $force_reauth Whether to force reauth.
632 * @return string
633 */
634 public function filter_login_url( $login_url, $redirect = '', $force_reauth = false ) {
635 $login_url = home_url( $this->custom_login_slug . '/' );
636
637 if ( ! empty( $redirect ) ) {
638 $login_url = add_query_arg( 'redirect_to', rawurlencode( $redirect ), $login_url );
639 }
640
641 if ( $force_reauth ) {
642 $login_url = add_query_arg( 'reauth', '1', $login_url );
643 }
644
645 return $login_url;
646 }
647
648 /**
649 * Filter logout URL
650 *
651 * @param string $logout_url The logout URL.
652 * @param string $redirect The redirect URL.
653 * @return string
654 */
655 public function filter_logout_url( $logout_url, $redirect = '' ) {
656 $args = array( 'action' => 'logout' );
657
658 if ( ! empty( $redirect ) ) {
659 $args['redirect_to'] = rawurlencode( $redirect );
660 }
661
662 $logout_url = add_query_arg( $args, home_url( $this->custom_login_slug . '/' ) );
663 $logout_url = wp_nonce_url( $logout_url, 'log-out' );
664
665 return $logout_url;
666 }
667
668 /**
669 * Filter lost password URL
670 *
671 * @param string $lostpassword_url The lost password URL.
672 * @param string $redirect The redirect URL.
673 * @return string
674 */
675 public function filter_lostpassword_url( $lostpassword_url, $redirect = '' ) {
676 $args = array( 'action' => 'lostpassword' );
677
678 if ( ! empty( $redirect ) ) {
679 $args['redirect_to'] = rawurlencode( $redirect );
680 }
681
682 return add_query_arg( $args, home_url( $this->custom_login_slug . '/' ) );
683 }
684
685 /**
686 * Filter register URL
687 *
688 * @param string $register_url The register URL.
689 * @return string
690 */
691 public function filter_register_url( $register_url ) {
692 return add_query_arg( 'action', 'register', home_url( $this->custom_login_slug . '/' ) );
693 }
694
695 /**
696 * Redirect after a successful lost-password request to the custom login URL
697 *
698 * Without this filter, core sends the user to wp-login.php?checkemail=confirm,
699 * which 404s when the custom login URL is enabled (block_wp_login_access only
700 * whitelists requests with a known action= parameter). Sending the user back
701 * to the custom login URL with the same query string lets wp-login.php render
702 * the "Check your email" confirmation correctly.
703 *
704 * @param string $redirect_to The default redirect URL.
705 * @return string
706 */
707 public function filter_lostpassword_redirect( $redirect_to ) {
708 return add_query_arg( 'checkemail', 'confirm', home_url( $this->custom_login_slug . '/' ) );
709 }
710
711 /**
712 * Filter site_url to replace wp-login.php in login form action
713 *
714 * @param string $url The complete site URL.
715 * @param string $path Path relative to the site URL.
716 * @param string|null $scheme Scheme to give the site URL context.
717 * @param int|null $blog_id Site ID, or null for the current site.
718 * @return string
719 */
720 public function filter_site_url( $url, $path, $scheme, $blog_id ) {
721 if ( 'login_post' === $scheme || 'login' === $scheme ) {
722 if ( strpos( $path, 'wp-login.php' ) !== false ) {
723 $url = str_replace( 'wp-login.php', $this->custom_login_slug . '/', $url );
724 }
725 }
726 return $url;
727 }
728
729 /**
730 * Filter logout redirect to go to home instead of wp-login.php
731 *
732 * @param string $redirect_to The redirect destination URL.
733 * @param string $requested_redirect_to The requested redirect destination URL.
734 * @param WP_User $user The WP_User object for the logged out user.
735 * @return string
736 */
737 public function filter_logout_redirect( $redirect_to, $requested_redirect_to, $user ) {
738 // If no specific redirect requested, go to home page
739 if ( empty( $requested_redirect_to ) || strpos( $redirect_to, 'wp-login.php' ) !== false ) {
740 return home_url( '/' );
741 }
742 return $redirect_to;
743 }
744
745 /**
746 * Show 404 page (full version with theme template)
747 * Use this only when WordPress is fully loaded (login_init, template_redirect, etc.)
748 */
749 private function show_404() {
750
751 global $wp_query;
752
753 // Set 404 status
754 status_header( 404 );
755 nocache_headers();
756
757 // $wp_query always exists by now: wp-settings.php creates it before
758 // 'init' fires, and serve_404() only routes here once 'wp_loaded' has
759 // passed. Earlier versions called wp() when it was missing, a branch
760 // that was never reachable and that misled a performance analysis into
761 // blaming the main query for the cost of the render.
762 if ( isset( $wp_query ) && is_object( $wp_query ) ) {
763 $wp_query->set_404();
764 }
765
766 // Try to get the theme's 404 template
767 $template = get_query_template( '404' );
768
769
770 if ( $template && file_exists( $template ) ) {
771 include $template;
772 exit;
773 }
774
775 // Block themes have no 404.php; resolve their 404 template the same
776 // way core's template-loader does, so those sites also get the
777 // theme's 404 instead of the plain fallback page.
778 if ( function_exists( 'locate_block_template' ) ) {
779 $template = locate_block_template( '', '404', array( '404' ) );
780
781 if ( $template && file_exists( $template ) ) {
782 include $template;
783 exit;
784 }
785 }
786
787 // Fallback to simple 404
788 $this->show_404_simple();
789 }
790
791 /**
792 * Serve the hidden-URL 404 through a single decision point.
793 *
794 * All blocking paths call this helper so the response never diverges by
795 * accident. Rendering a theme template is only safe once 'wp_loaded' has
796 * fired: that is the point the rest of the stack assumes has passed
797 * before any template runs, and WooCommerce for one does not set up the
798 * cart until then. block_wp_login_access() runs at 'login_init' and
799 * block_login_shortcuts() at 'template_redirect', both after 'wp_loaded',
800 * so those keep the themed 404; block_wp_admin_access() runs inside
801 * 'init' and gets the simple page.
802 *
803 * 2.9.3 gated this on 'after_setup_theme', which has already fired by
804 * 'init'. The wp-admin path therefore included the theme's 404.php from
805 * inside 'init' on every blocked request, filling debug.log with
806 * _doing_it_wrong notices and costing a full page render per rejection.
807 *
808 * @since 2.9.3
809 */
810 private function serve_404() {
811 if ( did_action( 'wp_loaded' ) && ! is_admin() ) {
812 $this->show_404();
813 }
814
815 $this->show_404_simple();
816 }
817
818 /**
819 * Show simple 404 page (for early execution before WordPress is fully loaded)
820 * Use this when intercepting requests very early (plugins_loaded, admin init, etc.)
821 */
822 private function show_404_simple() {
823 status_header( 404 );
824 nocache_headers();
825
826 // Use wp_die which is the WordPress standard for early termination
827 wp_die(
828 sprintf(
829 '<h1>%s</h1><p>%s</p><p><a href="%s">%s</a></p>',
830 esc_html__( 'Page not found', 'vigilante' ),
831 esc_html__( 'The page you are looking for does not exist.', 'vigilante' ),
832 esc_url( home_url( '/' ) ),
833 esc_html__( 'Go to homepage', 'vigilante' )
834 ),
835 esc_html__( '404 Not Found', 'vigilante' ),
836 array(
837 'response' => 404,
838 'back_link' => false,
839 )
840 );
841 }
842
843 /**
844 * Check if user is locked out
845 *
846 * @param WP_User|WP_Error|null $user User object or error.
847 * @param string $username Username.
848 * @param string $password Password.
849 * @return WP_User|WP_Error
850 */
851 public function check_lockout( $user, $username, $password ) {
852 // Skip if already error or empty credentials
853 if ( empty( $username ) || empty( $password ) ) {
854 return $user;
855 }
856
857 $ip = $this->database->get_client_ip();
858
859 // Check IP whitelist
860 if ( $this->is_ip_whitelisted( $ip ) ) {
861 return $user;
862 }
863
864 // Check if locked out
865 $lockout = $this->database->is_locked_out( $ip );
866
867 if ( $lockout ) {
868 $remaining = strtotime( $lockout['lockout_until'] ) - time();
869 $minutes = ceil( $remaining / 60 );
870
871 // Log the blocked attempt
872 if ( $this->activity_log ) {
873 $this->activity_log->log(
874 'login',
875 'lockout_blocked',
876 sprintf(
877 /* translators: %s: Username */
878 __( 'Login attempt blocked due to lockout: %s', 'vigilante' ),
879 $username
880 ),
881 array(
882 'ip' => $ip,
883 'username' => $username,
884 'lockout_until' => $lockout['lockout_until'],
885 ),
886 'warning'
887 );
888 }
889
890 return new WP_Error(
891 'vigilante_lockout',
892 sprintf(
893 /* translators: %d: Minutes remaining */
894 __( '<strong>Error</strong>: Too many failed login attempts. Please try again in %d minutes.', 'vigilante' ),
895 $minutes
896 )
897 );
898 }
899
900 return $user;
901 }
902
903 /**
904 * Handle failed login attempt
905 *
906 * @param string $username Username that failed.
907 */
908 public function handle_failed_login( $username ) {
909 // Skip counting if this is a Vigilante-controlled rejection
910 // (pending approval, session limit, email verification, etc.)
911 if ( apply_filters( 'vigilante_skip_failed_login_count', false ) ) {
912 return;
913 }
914
915 $ip = $this->database->get_client_ip();
916
917 // Skip whitelisted IPs
918 if ( $this->is_ip_whitelisted( $ip ) ) {
919 return;
920 }
921
922 // Record the attempt
923 $this->database->record_login_attempt( $ip, $username, 'failed' );
924
925 // Log the attempt
926 if ( $this->activity_log ) {
927 $this->activity_log->log(
928 'login',
929 'failed',
930 sprintf(
931 /* translators: %s: Username */
932 __( 'Failed login attempt for username: %s', 'vigilante' ),
933 $username
934 ),
935 array(
936 'ip' => $ip,
937 'username' => $username,
938 ),
939 'warning'
940 );
941 }
942
943 // Check if should be locked out
944 $this->maybe_lockout( $ip, $username );
945 }
946
947 /**
948 * Check if IP should be locked out
949 *
950 * @param string $ip IP address.
951 * @param string $username Username.
952 */
953 public function maybe_lockout( $ip, $username ) {
954 $max_attempts = absint( $this->options['max_attempts'] ?? 5 );
955 $lockout_duration = absint( $this->options['lockout_duration'] ?? 1800 );
956
957 // Get failed attempts in the last hour
958 $failed_count = $this->database->get_failed_attempt_count( $ip, 60 );
959
960 if ( $failed_count >= $max_attempts ) {
961 // Calculate lockout duration with increment
962 if ( ! empty( $this->options['lockout_increment'] ) ) {
963 $previous_lockouts = $this->get_previous_lockout_count( $ip );
964 $lockout_duration = min(
965 $lockout_duration * pow( 2, $previous_lockouts ),
966 absint( $this->options['max_lockout_duration'] ?? 86400 )
967 );
968 }
969
970 // Set lockout
971 $this->database->set_lockout( $ip, $lockout_duration );
972
973 // Log the lockout
974 if ( $this->activity_log ) {
975 $this->activity_log->log(
976 'login',
977 'lockout',
978 sprintf(
979 /* translators: 1: IP address, 2: Duration in minutes */
980 __( 'IP %1$s locked out for %2$d minutes', 'vigilante' ),
981 $ip,
982 ceil( $lockout_duration / 60 )
983 ),
984 array(
985 'ip' => $ip,
986 'username' => $username,
987 'attempts' => $failed_count,
988 'duration' => $lockout_duration,
989 ),
990 'critical'
991 );
992 }
993
994 // Send notification if enabled
995 if ( ! empty( $this->options['notify_on_lockout'] ) ) {
996 $this->send_lockout_notification( $ip, $username, $failed_count, $lockout_duration );
997 }
998 }
999 }
1000
1001 /**
1002 * Record a failed login attempt (public wrapper)
1003 *
1004 * Use this method from external modules (like 2FA) to integrate with the lockout system.
1005 *
1006 * @param string $username Username or identifier.
1007 * @param string $context Context for logging (e.g., 'password', '2fa').
1008 */
1009 public function record_failed_attempt( $username, $context = 'password' ) {
1010 $ip = $this->database->get_client_ip();
1011
1012 // Skip whitelisted IPs
1013 if ( $this->is_ip_whitelisted( $ip ) ) {
1014 return;
1015 }
1016
1017 // Record the attempt
1018 $this->database->record_login_attempt( $ip, $username, 'failed' );
1019
1020 // Log the attempt
1021 if ( $this->activity_log ) {
1022 $this->activity_log->log(
1023 'login',
1024 'failed',
1025 sprintf(
1026 /* translators: 1: Username, 2: Context (password/2fa) */
1027 __( 'Failed login attempt for %1$s (%2$s verification)', 'vigilante' ),
1028 $username,
1029 $context
1030 ),
1031 array(
1032 'ip' => $ip,
1033 'username' => $username,
1034 'context' => $context,
1035 ),
1036 'warning'
1037 );
1038 }
1039
1040 // Check if should be locked out
1041 $this->maybe_lockout( $ip, $username );
1042 }
1043
1044 /**
1045 * Get remaining attempts before lockout
1046 *
1047 * @return int Remaining attempts, or -1 if whitelisted
1048 */
1049 public function get_remaining_attempts() {
1050 $ip = $this->database->get_client_ip();
1051
1052 if ( $this->is_ip_whitelisted( $ip ) ) {
1053 return -1;
1054 }
1055
1056 $max_attempts = absint( $this->options['max_attempts'] ?? 5 );
1057 $failed_count = $this->database->get_failed_attempt_count( $ip, 60 );
1058
1059 return max( 0, $max_attempts - $failed_count );
1060 }
1061
1062 /**
1063 * Get count of previous lockouts for an IP
1064 *
1065 * @param string $ip IP address.
1066 * @return int
1067 */
1068 private function get_previous_lockout_count( $ip ) {
1069 $transient_key = 'vigilante_lockout_count_' . md5( $ip );
1070 $count = get_transient( $transient_key );
1071
1072 if ( false === $count ) {
1073 $count = 0;
1074 }
1075
1076 // Increment and store
1077 set_transient( $transient_key, $count + 1, DAY_IN_SECONDS );
1078
1079 return $count;
1080 }
1081
1082 /**
1083 * Handle successful login
1084 *
1085 * @param string $user_login Username.
1086 * @param WP_User $user User object.
1087 */
1088 public function handle_successful_login( $user_login, $user ) {
1089 $ip = $this->database->get_client_ip();
1090
1091 // Clear any failed attempts for this IP
1092 $this->database->reset_login_attempts( $ip );
1093
1094 // Log the successful login
1095 if ( $this->activity_log ) {
1096 $this->activity_log->log(
1097 'login',
1098 'success',
1099 sprintf(
1100 /* translators: %s: Username */
1101 __( 'Successful login: %s', 'vigilante' ),
1102 $user_login
1103 ),
1104 array(
1105 'ip' => $ip,
1106 'user_id' => $user->ID,
1107 'role' => implode( ', ', $user->roles ),
1108 ),
1109 'info'
1110 );
1111 }
1112 }
1113
1114 /**
1115 * Detect Vigilant-specific error codes on the login page
1116 *
1117 * Hooked to 'wp_login_errors' (which receives the full WP_Error object,
1118 * unlike 'login_errors' that only sees the rendered message string).
1119 * If any of the codes we recognize is present, sets a flag so that
1120 * hide_login_errors() lets the message through. Matching by code is
1121 * locale-independent — checking the message string would break on
1122 * translated sites because __() returns the translation, not the
1123 * original English text.
1124 *
1125 * @param WP_Error $errors Errors object.
1126 * @param string $redirect_to Redirect URL.
1127 * @return WP_Error
1128 */
1129 public function detect_specific_login_error( $errors, $redirect_to ) {
1130 // wp_login_errors only fires for the login action, so reaching this
1131 // method means we are on the login screen — not register, lost-password
1132 // or reset-password, where masking the message makes no sense.
1133 $this->in_login_context = true;
1134
1135 if ( ! ( $errors instanceof WP_Error ) || ! $errors->has_errors() ) {
1136 return $errors;
1137 }
1138
1139 $allowed_codes = array(
1140 // Login Security
1141 'vigilante_lockout',
1142 // User Security
1143 'vigilante_force_reset',
1144 'pending_approval',
1145 'email_not_verified',
1146 'session_limit_exceeded',
1147 // Two-Factor Email
1148 'no_code',
1149 'code_expired',
1150 'code_used',
1151 // Two-Factor TOTP
1152 'code_reused',
1153 'invalid_format',
1154 'not_configured',
1155 'decrypt_failed',
1156 'invalid_backup',
1157 'no_backup_codes',
1158 'corrupt_data',
1159 );
1160
1161 foreach ( $errors->get_error_codes() as $code ) {
1162 if ( in_array( $code, $allowed_codes, true ) ) {
1163 $this->show_specific_login_error = true;
1164 break;
1165 }
1166 }
1167
1168 return $errors;
1169 }
1170
1171 /**
1172 * Hide login error messages
1173 *
1174 * Only masks errors on the login action. Register, lost-password and
1175 * reset-password share the login_errors filter but must keep their real
1176 * validation messages.
1177 *
1178 * @param string $error Error message.
1179 * @return string
1180 */
1181 public function hide_login_errors( $error ) {
1182 // The login_errors filter is fired by login_header() on every
1183 // wp-login.php screen, not just the login form. On register,
1184 // lost-password and reset-password the generic "Invalid username or
1185 // password" is meaningless, so only mask when we are actually on the
1186 // login action (detect_specific_login_error, hooked to the
1187 // login-only wp_login_errors filter, sets this flag).
1188 if ( ! $this->in_login_context ) {
1189 return $error;
1190 }
1191
1192 // Primary check: a recognized Vigilant error code was seen on the
1193 // wp_login_errors filter — let the message through verbatim.
1194 if ( $this->show_specific_login_error ) {
1195 return $error;
1196 }
1197
1198 // Fallback: English string match. Kept for cases where the message
1199 // arrives without going through wp_login_errors (e.g. a third-party
1200 // plugin filtering 'login_errors' directly), and as a safety net for
1201 // any allowed code we may have missed in detect_specific_login_error().
1202 // Note: this fallback won't match on translated sites — the
1203 // code-based check above is the locale-safe path.
1204 $allowed_patterns = array(
1205 'vigilante_lockout',
1206 'Account pending',
1207 'pending_approval',
1208 'email_not_verified',
1209 'verify your email',
1210 'session_limit',
1211 'too many active',
1212 'verification code',
1213 'authenticator app',
1214 'two-factor',
1215 'grace period',
1216 'Password reset required',
1217 );
1218
1219 foreach ( $allowed_patterns as $pattern ) {
1220 if ( stripos( $error, $pattern ) !== false ) {
1221 return $error;
1222 }
1223 }
1224
1225 return __( '<strong>Error</strong>: Invalid username or password.', 'vigilante' );
1226 }
1227
1228 /**
1229 * Remove shake animation error codes
1230 *
1231 * Keeps Vigilante-specific error codes to show the shake animation
1232 *
1233 * @param array $codes Error codes.
1234 * @return array
1235 */
1236 public function remove_shake_errors( $codes ) {
1237 // Keep shake for Vigilante-specific errors that indicate real problems
1238 // Do NOT include 2FA codes - the form transition should be smooth
1239 return array(
1240 'vigilante_lockout',
1241 'vigilante_force_reset',
1242 'pending_approval',
1243 'email_not_verified',
1244 'session_limit_exceeded',
1245 );
1246 }
1247
1248 /**
1249 * Disable XML-RPC server
1250 *
1251 * @param string $class Server class.
1252 * @return string
1253 */
1254 public function disable_xmlrpc_server( $class ) {
1255 return 'Vigilante_Disabled_XMLRPC_Server';
1256 }
1257
1258 /**
1259 * Disable XML-RPC pingback method
1260 *
1261 * @param array $methods XML-RPC methods.
1262 * @return array
1263 */
1264 public function disable_xmlrpc_pingback( $methods ) {
1265 unset( $methods['pingback.ping'] );
1266 unset( $methods['pingback.extensions.getPingbacks'] );
1267 return $methods;
1268 }
1269
1270 /**
1271 * Notify admin of admin login
1272 *
1273 * @param string $user_login Username.
1274 * @param WP_User $user User object.
1275 */
1276 public function notify_admin_login( $user_login, $user ) {
1277 // Only notify for admin users
1278 if ( ! user_can( $user, 'administrator' ) ) {
1279 return;
1280 }
1281
1282 $ip = $this->database->get_client_ip();
1283 $to = $this->get_notification_email();
1284
1285 $site_name = get_bloginfo( 'name' );
1286 $subject = sprintf(
1287 /* translators: 1: Site name, 2: Username */
1288 __( '[%1$s] Administrator login: %2$s', 'vigilante' ),
1289 $site_name,
1290 $user_login
1291 );
1292
1293 $body = Vigilante_Email_Template::p( __( 'An administrator login has been detected on your site.', 'vigilante' ) );
1294 $body .= Vigilante_Email_Template::data_table( array(
1295 __( 'User', 'vigilante' ) => $user_login,
1296 __( 'IP address', 'vigilante' ) => $ip,
1297 __( 'Date/Time', 'vigilante' ) => wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ),
1298 ) );
1299 $body .= Vigilante_Email_Template::warning_box( __( 'If this was not you, please check your site security immediately.', 'vigilante' ) );
1300
1301 Vigilante_Email_Template::send( $to, $subject, __( 'Administrator login detected', 'vigilante' ), $body );
1302 }
1303
1304 /**
1305 * Send lockout notification email
1306 *
1307 * @param string $ip IP address.
1308 * @param string $username Username.
1309 * @param int $attempts Number of attempts.
1310 * @param int $duration Lockout duration in seconds.
1311 */
1312 private function send_lockout_notification( $ip, $username, $attempts, $duration ) {
1313 $to = $this->get_notification_email();
1314 $site_name = get_bloginfo( 'name' );
1315
1316 $subject = sprintf(
1317 /* translators: %s: Site name */
1318 __( '[%s] Login lockout triggered', 'vigilante' ),
1319 $site_name
1320 );
1321
1322 $body = Vigilante_Email_Template::alert_box( __( 'A login lockout has been triggered on your site. The IP address has been temporarily blocked.', 'vigilante' ) );
1323 $body .= Vigilante_Email_Template::data_table( array(
1324 __( 'IP address', 'vigilante' ) => $ip,
1325 __( 'Username attempted', 'vigilante' ) => $username,
1326 __( 'Failed attempts', 'vigilante' ) => (string) $attempts,
1327 __( 'Lockout duration', 'vigilante' ) => ceil( $duration / 60 ) . ' ' . __( 'minutes', 'vigilante' ),
1328 __( 'Date/Time', 'vigilante' ) => wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ),
1329 ) );
1330 $body .= Vigilante_Email_Template::button( admin_url( 'admin.php?page=vigilante&tab=login' ), __( 'View lockouts', 'vigilante' ) );
1331
1332 Vigilante_Email_Template::send( $to, $subject, __( 'Login lockout triggered', 'vigilante' ), $body, true );
1333 }
1334
1335 /**
1336 * Show remaining attempts on login form
1337 */
1338 public function show_remaining_attempts() {
1339 $ip = $this->database->get_client_ip();
1340
1341 if ( $this->is_ip_whitelisted( $ip ) ) {
1342 return;
1343 }
1344
1345 $max_attempts = absint( $this->options['max_attempts'] ?? 5 );
1346 $failed_count = $this->database->get_failed_attempt_count( $ip, 60 );
1347
1348 if ( $failed_count > 0 && $failed_count < $max_attempts ) {
1349 $remaining = $max_attempts - $failed_count;
1350 ?>
1351 <p class="vigilante-login-warning" style="background: #fff3cd; border-left: 4px solid #ffc107; padding: 10px; margin-bottom: 15px;">
1352 <?php
1353 printf(
1354 /* translators: %d: Number of remaining attempts */
1355 esc_html( _n(
1356 'Warning: %d login attempt remaining before lockout.',
1357 'Warning: %d login attempts remaining before lockout.',
1358 $remaining,
1359 'vigilante'
1360 ) ),
1361 absint( $remaining )
1362 );
1363 ?>
1364 </p>
1365 <?php
1366 }
1367 }
1368
1369 /**
1370 * Check if IP is whitelisted
1371 *
1372 * @param string $ip IP address.
1373 * @return bool
1374 */
1375 private function is_ip_whitelisted( $ip ) {
1376 $whitelist = $this->options['ip_whitelist'] ?? array();
1377
1378 return Vigilante_IP_Utils::in_list( $ip, $whitelist );
1379 }
1380
1381 /**
1382 * Whether the current request comes from an IP that may bypass the
1383 * hidden-login / hidden-wp-admin masking.
1384 *
1385 * Reads the firewall's global IP whitelist (the visible "IP whitelist"
1386 * box) so trusted services such as MainWP or ManageWP, which reach
1387 * wp-admin without a WordPress session cookie, are not turned away with
1388 * a 404. This relaxes only the URL masking, never authentication: an
1389 * exempt IP still has to log in normally.
1390 *
1391 * @return bool
1392 */
1393 private function is_ip_exempt_from_hiding() {
1394 $whitelist = $this->settings->get_option( 'firewall', 'ip_whitelist', array() );
1395
1396 if ( empty( $whitelist ) ) {
1397 return false;
1398 }
1399
1400 return Vigilante_IP_Utils::in_list( $this->database->get_client_ip(), $whitelist );
1401 }
1402
1403 /**
1404 * Get notification email
1405 *
1406 * @return string
1407 */
1408 /**
1409 * Get notification recipients (centralized)
1410 *
1411 * @return array Array of email addresses.
1412 */
1413 private function get_notification_email() {
1414 return Vigilante_Email_Template::get_admin_recipients();
1415 }
1416
1417 /**
1418 * Manually clear lockout for an IP
1419 *
1420 * @param string $ip IP address.
1421 * @return bool
1422 */
1423 public function clear_lockout( $ip ) {
1424 return $this->database->clear_lockout( $ip );
1425 }
1426
1427 /**
1428 * Get currently locked out IPs
1429 *
1430 * @return array
1431 */
1432 public function get_locked_out_ips() {
1433 return $this->database->get_locked_out_ips();
1434 }
1435
1436 /**
1437 * Get login statistics
1438 *
1439 * @param int $days Days to look back.
1440 * @return array
1441 */
1442 public function get_statistics( $days = 7 ) {
1443 global $wpdb;
1444
1445 $table = esc_sql( $this->database->get_login_attempts_table() );
1446 $since = gmdate( 'Y-m-d H:i:s', strtotime( "-{$days} days" ) );
1447
1448 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1449 $stats = $wpdb->get_row(
1450 $wpdb->prepare(
1451 "SELECT
1452 COUNT(CASE WHEN status = 'failed' THEN 1 END) as failed_attempts,
1453 COUNT(CASE WHEN status = 'lockout' THEN 1 END) as lockouts,
1454 COUNT(DISTINCT ip_address) as unique_ips,
1455 COUNT(DISTINCT username) as unique_usernames
1456 FROM `{$table}`
1457 WHERE last_attempt >= %s",
1458 $since
1459 ),
1460 ARRAY_A
1461 );
1462 // phpcs:enable
1463
1464 return $stats ? $stats : array(
1465 'failed_attempts' => 0,
1466 'lockouts' => 0,
1467 'unique_ips' => 0,
1468 'unique_usernames' => 0,
1469 );
1470 }
1471 }
1472
1473 /**
1474 * Disabled XML-RPC Server class
1475 */
1476 class Vigilante_Disabled_XMLRPC_Server {
1477 /**
1478 * Constructor - return error for any request
1479 */
1480 public function __construct() {
1481 // Return error for any XML-RPC request
1482 header( 'HTTP/1.1 403 Forbidden' );
1483 header( 'Content-Type: text/plain' );
1484 die( 'XML-RPC is disabled' );
1485 }
1486 }