PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.9.7
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.9.7
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.7, at includes/class-login-security.php

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