PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.8
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.8
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 2.9.4 All 87 releases
vigilante / includes / class-user-security.php

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

3,024 lines 114.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * User Security Class
4 *
5 * Handles user security validations and protections
6 *
7 * @package Vigilante
8 */
9
10 // Prevent direct access
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Class Vigilante_User_Security
17 *
18 * Manages user security features
19 */
20 class Vigilante_User_Security {
21
22 /**
23 * Settings instance
24 *
25 * @var Vigilante_Settings
26 */
27 private $settings;
28
29 /**
30 * Activity log instance
31 *
32 * @var Vigilante_Activity_Log
33 */
34 private $activity_log;
35
36 /**
37 * User security options
38 *
39 * @var array
40 */
41 private $options;
42
43 /**
44 * Constructor
45 *
46 * @param Vigilante_Settings $settings Settings instance.
47 * @param Vigilante_Activity_Log $activity_log Activity log instance.
48 */
49 public function __construct( $settings, $activity_log ) {
50 $this->settings = $settings;
51 $this->activity_log = $activity_log;
52 $this->options = $settings->get_section( 'user_security' );
53
54 $this->init_hooks();
55 }
56
57 /**
58 * Initialize hooks
59 */
60 private function init_hooks() {
61 // Block insecure usernames
62 if ( ! empty( $this->options['block_insecure_usernames'] ) ) {
63 add_action( 'user_profile_update_errors', array( $this, 'validate_username' ), 10, 3 );
64 add_filter( 'pre_user_login', array( $this, 'check_username_on_create' ) );
65 add_action( 'register_post', array( $this, 'validate_registration_username' ), 10, 3 );
66 }
67
68 // Warn about existing insecure users (always active, independent of settings)
69 add_action( 'admin_notices', array( $this, 'show_insecure_user_warning' ) );
70 add_action( 'wp_ajax_vigilante_dismiss_insecure_warning', array( $this, 'ajax_dismiss_insecure_warning' ) );
71
72 // Block author scanning — must run BEFORE WordPress core's redirect_canonical()
73 // (also on template_redirect, default priority 10), which would otherwise redirect
74 // /?author=N to /author/USERNAME/ and leak the login. Priority 1 puts our redirect
75 // first so the username never reaches the response.
76 if ( ! empty( $this->options['block_author_scanning'] ) ) {
77 add_action( 'template_redirect', array( $this, 'block_author_scan' ), 1 );
78 }
79
80 // Block user enumeration via REST API
81 if ( ! empty( $this->options['disable_user_rest_enum'] ) ) {
82 add_filter( 'rest_endpoints', array( $this, 'disable_user_endpoints' ) );
83 }
84
85 // Force strong passwords
86 if ( ! empty( $this->options['force_strong_passwords'] ) ) {
87 add_action( 'user_profile_update_errors', array( $this, 'validate_password_strength' ), 10, 3 );
88 add_filter( 'registration_errors', array( $this, 'validate_registration_password' ), 10, 3 );
89 }
90
91 // Prevent display name matching login username
92 // Also enforced during Under Attack mode regardless of setting
93 $under_attack = get_option( 'vigilante_under_attack_mode', array() );
94 if ( ! empty( $this->options['prevent_display_name_login_match'] ) || ! empty( $under_attack['active'] ) ) {
95 add_action( 'user_profile_update_errors', array( $this, 'validate_display_name' ), 10, 3 );
96 }
97
98 // Log user changes and admin monitoring
99 add_action( 'profile_update', array( $this, 'log_profile_update' ), 10, 2 );
100 add_action( 'user_register', array( $this, 'log_user_register' ) );
101 add_action( 'delete_user', array( $this, 'log_user_delete' ) );
102 add_action( 'set_user_role', array( $this, 'log_role_change' ), 10, 3 );
103
104 // Registration approval
105 $registration_approval = $this->options['registration_approval'] ?? array();
106 if ( ! empty( $registration_approval['enabled'] ) ) {
107 add_action( 'user_register', array( $this, 'set_user_pending_approval' ), 5 );
108 add_filter( 'wp_authenticate_user', array( $this, 'block_pending_user_login' ), 15, 2 );
109 add_action( 'admin_notices', array( $this, 'show_pending_users_notice' ) );
110 }
111
112 // Session limits
113 $session_limits = $this->options['session_limits'] ?? array();
114 if ( ! empty( $session_limits['enabled'] ) ) {
115 // For block_new: check BEFORE login completes
116 if ( 'block_new' === ( $session_limits['behavior'] ?? 'block_new' ) ) {
117 add_filter( 'wp_authenticate_user', array( $this, 'check_session_limit_before_login' ), 20, 2 );
118 }
119 // For close_oldest: handle AFTER login
120 add_action( 'wp_login', array( $this, 'enforce_session_limit' ), 10, 2 );
121 }
122
123 // Admin password change monitoring (independent of password expiration)
124 $admin_monitoring = $this->options['admin_monitoring'] ?? array();
125 if ( ! empty( $admin_monitoring['alert_admin_password_change'] ) ) {
126 add_action( 'profile_update', array( $this, 'check_admin_password_change' ), 10, 2 );
127 }
128
129 // Password expiration
130 $password_expiration = $this->options['password_expiration'] ?? array();
131 if ( ! empty( $password_expiration['enabled'] ) ) {
132 add_action( 'wp_login', array( $this, 'check_password_expiration' ), 10, 2 );
133 add_action( 'admin_notices', array( $this, 'show_password_expiration_notice' ) );
134 add_action( 'admin_init', array( $this, 'force_password_change_redirect' ) );
135 add_action( 'profile_update', array( $this, 'update_password_change_date' ), 10, 2 );
136 add_action( 'user_register', array( $this, 'set_initial_password_date' ) );
137 add_action( 'user_profile_update_errors', array( $this, 'check_password_history' ), 10, 3 );
138
139 // Email reminder cron
140 if ( ! empty( $password_expiration['send_reminder'] ) ) {
141 add_action( 'vigilante_password_expiry_reminder', array( $this, 'send_password_expiry_reminders' ) );
142 if ( ! wp_next_scheduled( 'vigilante_password_expiry_reminder' ) ) {
143 wp_schedule_event( time(), 'daily', 'vigilante_password_expiry_reminder' );
144 }
145 }
146 }
147
148 // Email verification
149 $email_verification = $this->options['email_verification'] ?? array();
150 if ( ! empty( $email_verification['enabled'] ) ) {
151 add_action( 'user_register', array( $this, 'send_verification_email' ), 15 );
152 add_filter( 'wp_authenticate_user', array( $this, 'block_unverified_user_login' ), 10, 2 );
153 add_action( 'init', array( $this, 'handle_email_verification' ) );
154 add_action( 'login_message', array( $this, 'show_verification_message' ) );
155 }
156
157 // Registration flow control - suppress WP email and show custom messages
158 if ( ! empty( $registration_approval['enabled'] ) || ! empty( $email_verification['enabled'] ) ) {
159 add_filter( 'wp_new_user_notification_email', array( $this, 'suppress_new_user_email' ), 10, 3 );
160 add_filter( 'registration_redirect', array( $this, 'custom_registration_redirect' ) );
161 add_action( 'login_message', array( $this, 'show_registration_pending_message' ) );
162 }
163
164 // Force password reset login message (always active, independent of settings)
165 add_filter( 'authenticate', array( $this, 'check_force_reset_on_login' ), 30, 3 );
166 add_action( 'after_password_reset', array( $this, 'clear_force_reset_meta' ), 10, 1 );
167 }
168
169 /**
170 * Validate username on profile update
171 *
172 * @param WP_Error $errors Error object.
173 * @param bool $update Whether this is an update.
174 * @param WP_User $user User object.
175 */
176 public function validate_username( $errors, $update, $user ) {
177 if ( $update ) {
178 return; // Can't change username on update
179 }
180
181 $username = isset( $user->user_login ) ? $user->user_login : '';
182
183 if ( $this->is_insecure_username( $username ) ) {
184 $errors->add(
185 'insecure_username',
186 sprintf(
187 /* translators: %s: Username */
188 __( '<strong>Error</strong>: The username "%s" is not allowed for security reasons. Please choose a different username.', 'vigilante' ),
189 esc_html( $username )
190 )
191 );
192 }
193 }
194
195 /**
196 * Check username before creation
197 *
198 * @param string $username Username.
199 * @return string
200 */
201 public function check_username_on_create( $username ) {
202 if ( $this->is_insecure_username( $username ) ) {
203 // Log the attempt
204 if ( $this->activity_log ) {
205 $this->activity_log->log(
206 'user',
207 'insecure_username_blocked',
208 sprintf(
209 /* translators: %s: Username */
210 __( 'Attempted to create user with insecure username: %s', 'vigilante' ),
211 $username
212 ),
213 array( 'username' => $username ),
214 'warning'
215 );
216 }
217 }
218 return $username;
219 }
220
221 /**
222 * Validate username during registration
223 *
224 * @param string $sanitized_user_login Username.
225 * @param string $user_email Email.
226 * @param WP_Error $errors Error object.
227 */
228 public function validate_registration_username( $sanitized_user_login, $user_email, $errors ) {
229 if ( $this->is_insecure_username( $sanitized_user_login ) ) {
230 $errors->add(
231 'insecure_username',
232 __( '<strong>Error</strong>: This username is not allowed for security reasons. Please choose a different username.', 'vigilante' )
233 );
234 }
235 }
236
237 /**
238 * Check if username is insecure
239 *
240 * @param string $username Username to check.
241 * @return bool
242 */
243 private function is_insecure_username( $username ) {
244 $username = strtolower( trim( $username ) );
245 $insecure_usernames = $this->options['insecure_usernames'] ?? array();
246
247 return in_array( $username, array_map( 'strtolower', $insecure_usernames ), true );
248 }
249
250 /**
251 * Show warning if insecure admin users exist
252 */
253 public function show_insecure_user_warning() {
254 // Only show to administrators
255 if ( ! current_user_can( 'manage_options' ) ) {
256 return;
257 }
258
259 // Find insecure accounts first; if there are none there is nothing to warn
260 // about and we skip any further state read.
261 $found_users = $this->get_insecure_users();
262
263 if ( empty( $found_users ) ) {
264 return;
265 }
266
267 // The warning is a standing reminder, so it always shows on the Dashboard
268 // (index.php). On every other admin screen it is dismissible per
269 // administrator, but the dismissal records WHICH insecure usernames were
270 // present when it was closed: closing it silences only those. If a new
271 // insecure account shows up later, the warning comes back instead of
272 // staying hidden forever. The Dashboard always shows it while the issue
273 // remains unresolved.
274 global $pagenow;
275 $is_dashboard = ( 'index.php' === $pagenow );
276
277 if ( ! $is_dashboard ) {
278 $dismissed = get_user_meta( get_current_user_id(), 'vigilante_dismissed_insecure_users', true );
279 $dismissed = is_array( $dismissed ) ? $dismissed : array();
280
281 // Stay hidden only while every currently-found user was already dismissed.
282 if ( empty( array_diff( $found_users, $dismissed ) ) ) {
283 return;
284 }
285 }
286
287 $escaped_users = array_map( 'esc_html', $found_users );
288 $usernames_html = '<code>' . implode( '</code>, <code>', $escaped_users ) . '</code>';
289 ?>
290 <div class="notice notice-error is-dismissible" data-vigilante-notice="insecure_users">
291 <p>
292 <strong><?php esc_html_e( 'Security Alert!', 'vigilante' ); ?></strong>
293 </p>
294 <p>
295 <?php
296 printf(
297 /* translators: %s: Comma-separated list of usernames in <code> tags */
298 esc_html__( 'The following accounts use insecure usernames that are commonly targeted in brute force attacks: %s', 'vigilante' ),
299 wp_kses( $usernames_html, array( 'code' => array() ) )
300 );
301 ?>
302 </p>
303 <p>
304 <?php esc_html_e( 'For security, create new accounts with unique usernames and delete these.', 'vigilante' ); ?>
305 </p>
306 </div>
307 <script>
308 ( function () {
309 var notice = document.querySelector( '.notice[data-vigilante-notice="insecure_users"]' );
310 if ( ! notice ) {
311 return;
312 }
313 // The dismiss button is injected by core after load, so delegate from
314 // the notice element and persist the dismissal for this user.
315 notice.addEventListener( 'click', function ( e ) {
316 if ( ! e.target || ! e.target.classList.contains( 'notice-dismiss' ) ) {
317 return;
318 }
319 var data = new FormData();
320 data.append( 'action', 'vigilante_dismiss_insecure_warning' );
321 data.append( 'nonce', '<?php echo esc_js( wp_create_nonce( 'vigilante_dismiss_insecure_warning' ) ); ?>' );
322 if ( navigator.sendBeacon ) {
323 navigator.sendBeacon( ajaxurl, data );
324 } else {
325 var xhr = new XMLHttpRequest();
326 xhr.open( 'POST', ajaxurl, true );
327 xhr.send( data );
328 }
329 } );
330 } )();
331 </script>
332 <?php
333 }
334
335 /**
336 * Persist per-user dismissal of the insecure-usernames warning.
337 *
338 * Stores the set of insecure usernames present at dismissal time, so the
339 * warning stays hidden for this admin only while those exact accounts remain;
340 * a new insecure account brings it back. It still re-appears on the Dashboard.
341 */
342 public function ajax_dismiss_insecure_warning() {
343 check_ajax_referer( 'vigilante_dismiss_insecure_warning', 'nonce' );
344
345 if ( ! current_user_can( 'manage_options' ) ) {
346 wp_send_json_error();
347 }
348
349 update_user_meta( get_current_user_id(), 'vigilante_dismissed_insecure_users', $this->get_insecure_users() );
350
351 wp_send_json_success();
352 }
353
354 /**
355 * List the insecure-by-name accounts currently present.
356 *
357 * @return string[] Matching logins.
358 */
359 private function get_insecure_users() {
360 $priority_usernames = array( 'admin', 'administrator', 'root', 'test', 'user', 'guest', 'info', 'sysadmin', 'webmaster' );
361 $found = array();
362
363 foreach ( $priority_usernames as $username ) {
364 if ( get_user_by( 'login', $username ) ) {
365 $found[] = $username;
366 }
367 }
368
369 return $found;
370 }
371
372 /**
373 * Block author scanning via URL
374 */
375 public function block_author_scan() {
376 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
377 if ( isset( $_GET['author'] ) && is_numeric( $_GET['author'] ) ) {
378 // Log the attempt
379 if ( $this->activity_log ) {
380 $this->activity_log->log(
381 'user',
382 'author_scan_blocked',
383 __( 'Author enumeration attempt blocked', 'vigilante' ),
384 array(
385 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
386 'author_id' => absint( $_GET['author'] ),
387 ),
388 'warning'
389 );
390 }
391
392 // Redirect to homepage
393 wp_safe_redirect( home_url(), 301 );
394 exit;
395 }
396 }
397
398 /**
399 * Disable user endpoints in REST API
400 *
401 * @param array $endpoints REST API endpoints.
402 * @return array Modified endpoints.
403 */
404 public function disable_user_endpoints( $endpoints ) {
405 // Only for non-logged in users
406 if ( is_user_logged_in() ) {
407 return $endpoints;
408 }
409
410 $endpoints_to_remove = array(
411 '/wp/v2/users',
412 '/wp/v2/users/(?P<id>[\d]+)',
413 );
414
415 foreach ( $endpoints_to_remove as $endpoint ) {
416 if ( isset( $endpoints[ $endpoint ] ) ) {
417 unset( $endpoints[ $endpoint ] );
418 }
419 }
420
421 return $endpoints;
422 }
423
424 // =========================================================================
425 // Display Name Protection - Prevent display name matching login
426 // =========================================================================
427
428 /**
429 * Prevent users from saving a display name that matches their login username
430 *
431 * The display name is publicly visible (author archives, comments, REST API).
432 * If it matches the login username, the login is exposed to attackers.
433 *
434 * @param WP_Error $errors Error object.
435 * @param bool $update Whether this is an update.
436 * @param WP_User $user User object.
437 */
438 public function validate_display_name( $errors, $update, $user ) {
439 if ( ! $update || ! isset( $user->ID ) ) {
440 return;
441 }
442
443 // Get the display name being saved
444 // phpcs:ignore WordPress.Security.NonceVerification.Missing
445 $display_name = isset( $_POST['display_name'] ) ? sanitize_text_field( wp_unslash( $_POST['display_name'] ) ) : '';
446
447 if ( empty( $display_name ) ) {
448 return;
449 }
450
451 // Get the actual login username
452 $user_data = get_userdata( $user->ID );
453 if ( ! $user_data ) {
454 return;
455 }
456
457 // Nothing to enforce unless the display name being saved equals the login.
458 if ( strcasecmp( $display_name, $user_data->user_login ) !== 0 ) {
459 return;
460 }
461
462 // The display name equals the login, which is the unsafe state we want to
463 // prevent (it exposes the login publicly). Block it — EXCEPT when this same
464 // save is also changing the password. That is the forced password-change
465 // flow for a legacy "display == login" account: aborting it would dead-lock
466 // the change (the password never updates and the user is bounced on every
467 // load). WordPress exposes the new plaintext password as $user->user_pass
468 // during this hook, so a value different from the stored hash means a
469 // password change is in progress; in that case we let the save through.
470 $changing_password = isset( $user->user_pass ) && '' !== $user->user_pass && $user->user_pass !== $user_data->user_pass;
471 if ( $changing_password ) {
472 return;
473 }
474
475 $errors->add(
476 'display_name_login_match',
477 __( '<strong>Error</strong>: Your display name cannot be the same as your login username. The display name is publicly visible and would expose your login credentials.', 'vigilante' )
478 );
479 }
480
481 /**
482 * Validate password strength
483 *
484 * @param WP_Error $errors Error object.
485 * @param bool $update Whether this is an update.
486 * @param WP_User $user User object.
487 */
488 public function validate_password_strength( $errors, $update, $user ) {
489 // During user_profile_update_errors WordPress exposes the new password
490 // (still plaintext, only slashed) as $user->user_pass. Reading it from the
491 // object instead of $_POST keeps this validator free of input/nonce sniffs
492 // AND avoids sanitizing the password: sanitize_text_field() would strip
493 // "<...>", tabs and repeated spaces, mismeasure the value and wrongly reject
494 // valid passwords, aborting a forced change and leaving the old one active.
495 if ( ! isset( $user->user_pass ) || '' === $user->user_pass ) {
496 return;
497 }
498
499 $username = isset( $user->user_login ) ? $user->user_login : '';
500 $roles = array();
501
502 if ( $update && isset( $user->ID ) ) {
503 $user_data = get_userdata( $user->ID );
504 if ( $user_data ) {
505 // On a profile save that doesn't touch the password, user_pass is
506 // still the stored hash, not a new plaintext value: nothing to check.
507 if ( $user->user_pass === $user_data->user_pass ) {
508 return;
509 }
510 $username = $user_data->user_login;
511 $roles = $user_data->roles;
512 }
513 } elseif ( ! empty( $user->role ) ) {
514 // New account created from wp-admin: the chosen role is on the object.
515 $roles = array( $user->role );
516 }
517
518 if ( ! empty( $roles ) && ! $this->password_policy_applies( $roles ) ) {
519 return;
520 }
521
522 $password = (string) wp_unslash( $user->user_pass );
523 $strength_errors = $this->check_password_strength( $password, $username );
524
525 foreach ( $strength_errors as $error ) {
526 $errors->add( 'weak_password', $error );
527 }
528 }
529
530 /**
531 * Validate password on registration
532 *
533 * @param WP_Error $errors Error object.
534 * @param string $sanitized_user_login Username.
535 * @param string $user_email Email.
536 * @return WP_Error
537 */
538 public function validate_registration_password( $errors, $sanitized_user_login, $user_email ) {
539 // phpcs:ignore WordPress.Security.NonceVerification.Missing
540 if ( empty( $_POST['user_pass'] ) ) {
541 return $errors;
542 }
543
544 // Registration runs on a public form with no $user object carrying the
545 // password, so it must read $_POST. Unlike the profile path there is no
546 // "saved but unrecognized" trap here (the account isn't created until the
547 // password passes), so the mild mismeasure sanitize_text_field() can cause
548 // on exotic characters is an acceptable trade for not suppressing a
549 // security sniff on a public endpoint.
550 // phpcs:ignore WordPress.Security.NonceVerification.Missing
551 $password = sanitize_text_field( wp_unslash( $_POST['user_pass'] ) );
552
553 // New registrations receive the site's default role; skip enforcement if
554 // the policy is scoped to roles that don't include it.
555 if ( ! $this->password_policy_applies( array( get_option( 'default_role', 'subscriber' ) ) ) ) {
556 return $errors;
557 }
558
559 $strength_errors = $this->check_password_strength( $password, $sanitized_user_login );
560
561 foreach ( $strength_errors as $error ) {
562 $errors->add( 'weak_password', $error );
563 }
564
565 return $errors;
566 }
567
568 /**
569 * Read the granular password policy merged with safe defaults.
570 *
571 * Defaults reproduce the historical all-requirements behaviour so a site
572 * upgrading from before the policy existed keeps the same rules.
573 *
574 * @return array
575 */
576 private function get_password_policy() {
577 return wp_parse_args(
578 $this->options['password_policy'] ?? array(),
579 array(
580 'require_uppercase' => true,
581 'require_lowercase' => true,
582 'require_number' => true,
583 'require_special' => true,
584 'block_common' => true,
585 'block_username' => false,
586 'affected_roles' => array(),
587 )
588 );
589 }
590
591 /**
592 * Whether the password policy applies to a user with the given roles.
593 *
594 * @param array $roles Role slugs.
595 * @return bool
596 */
597 private function password_policy_applies( $roles ) {
598 $affected = (array) ( $this->get_password_policy()['affected_roles'] );
599
600 // Empty list = apply to every role.
601 if ( empty( $affected ) ) {
602 return true;
603 }
604
605 return (bool) array_intersect( (array) $roles, $affected );
606 }
607
608 /**
609 * Check password strength against the configured policy
610 *
611 * @param string $password Password to check.
612 * @param string $username Login name, for the "don't contain username" rule.
613 * @return array Array of error messages (empty if password is strong).
614 */
615 public function check_password_strength( $password, $username = '' ) {
616 $errors = array();
617 $min_length = absint( $this->options['min_password_length'] ?? 12 );
618 $policy = $this->get_password_policy();
619
620 // Check length
621 if ( strlen( $password ) < $min_length ) {
622 $errors[] = sprintf(
623 /* translators: %d: Minimum password length */
624 __( 'Password must be at least %d characters long.', 'vigilante' ),
625 $min_length
626 );
627 }
628
629 // Character-class requirements (each one is individually optional)
630 if ( ! empty( $policy['require_uppercase'] ) && ! preg_match( '/[A-Z]/', $password ) ) {
631 $errors[] = __( 'Password must contain at least one uppercase letter.', 'vigilante' );
632 }
633
634 if ( ! empty( $policy['require_lowercase'] ) && ! preg_match( '/[a-z]/', $password ) ) {
635 $errors[] = __( 'Password must contain at least one lowercase letter.', 'vigilante' );
636 }
637
638 if ( ! empty( $policy['require_number'] ) && ! preg_match( '/[0-9]/', $password ) ) {
639 $errors[] = __( 'Password must contain at least one number.', 'vigilante' );
640 }
641
642 if ( ! empty( $policy['require_special'] ) && ! preg_match( '/[^a-zA-Z0-9]/', $password ) ) {
643 $errors[] = __( 'Password must contain at least one special character.', 'vigilante' );
644 }
645
646 // Don't allow the username inside the password. Guard on a minimum
647 // username length so trivial 1-3 char logins don't reject everything.
648 if ( ! empty( $policy['block_username'] ) && '' !== $username
649 && strlen( $username ) >= 4 && false !== stripos( $password, $username ) ) {
650 $errors[] = __( 'Password must not contain your username.', 'vigilante' );
651 }
652
653 // Check for common passwords
654 if ( ! empty( $policy['block_common'] ) ) {
655 $common_passwords = array(
656 'password', '123456', '12345678', 'qwerty', 'abc123',
657 'monkey', '1234567', 'letmein', 'trustno1', 'dragon',
658 'baseball', 'iloveyou', 'master', 'sunshine', 'ashley',
659 'bailey', 'passw0rd', 'shadow', '123123', '654321',
660 );
661
662 if ( in_array( strtolower( $password ), $common_passwords, true ) ) {
663 $errors[] = __( 'This password is too common. Please choose a more unique password.', 'vigilante' );
664 }
665 }
666
667 return $errors;
668 }
669
670 /**
671 * Log profile update and check for admin email changes
672 *
673 * @param int $user_id User ID.
674 * @param WP_User $old_user_data Old user data.
675 */
676 public function log_profile_update( $user_id, $old_user_data ) {
677 $user = get_userdata( $user_id );
678 $changes = array();
679 $is_admin = user_can( $user, 'administrator' );
680 $email_changed = $user->user_email !== $old_user_data->user_email;
681
682 if ( $email_changed ) {
683 $changes['email'] = array(
684 'old' => $old_user_data->user_email,
685 'new' => $user->user_email,
686 );
687 }
688
689 if ( $user->display_name !== $old_user_data->display_name ) {
690 $changes['display_name'] = array(
691 'old' => $old_user_data->display_name,
692 'new' => $user->display_name,
693 );
694
695 // Invalidate cached display name check for dashboard recommendation
696 delete_transient( 'vigilante_exposed_display_names' );
697 }
698
699 // Determine severity - admin email change is always warning
700 $severity = ( $is_admin && $email_changed ) ? 'warning' : 'info';
701
702 // Log the change
703 if ( $this->activity_log ) {
704 $this->activity_log->log(
705 'user',
706 'profile_updated',
707 sprintf(
708 /* translators: %s: Username */
709 __( 'User profile updated: %s', 'vigilante' ),
710 $user->user_login
711 ),
712 array(
713 'user_id' => $user_id,
714 'changes' => $changes,
715 ),
716 $severity
717 );
718 }
719
720 // Send alert for admin email change if enabled
721 if ( $is_admin && $email_changed ) {
722 $monitoring = $this->options['admin_monitoring'] ?? array();
723 if ( ! empty( $monitoring['alert_admin_email_change'] ) ) {
724 $this->send_admin_monitoring_alert(
725 'admin_email_change',
726 sprintf(
727 /* translators: 1: Username, 2: Old email, 3: New email */
728 __( 'Administrator email changed for user "%1$s": %2$s → %3$s', 'vigilante' ),
729 $user->user_login,
730 $old_user_data->user_email,
731 $user->user_email
732 ),
733 array(
734 'user_id' => $user_id,
735 'username' => $user->user_login,
736 'old_email' => $old_user_data->user_email,
737 'new_email' => $user->user_email,
738 )
739 );
740 }
741 }
742 }
743
744 /**
745 * Log user registration and check for new admin
746 *
747 * @param int $user_id User ID.
748 */
749 public function log_user_register( $user_id ) {
750 $user = get_userdata( $user_id );
751 $is_admin = user_can( $user, 'administrator' );
752 $severity = $is_admin ? 'warning' : 'info';
753
754 // Log the registration
755 if ( $this->activity_log ) {
756 $this->activity_log->log(
757 'user',
758 'registered',
759 sprintf(
760 /* translators: %s: Username */
761 __( 'New user registered: %s', 'vigilante' ),
762 $user->user_login
763 ),
764 array(
765 'user_id' => $user_id,
766 'email' => $user->user_email,
767 'role' => implode( ', ', $user->roles ),
768 ),
769 $severity
770 );
771 }
772
773 // Send alert for new admin if enabled
774 if ( $is_admin ) {
775 $monitoring = $this->options['admin_monitoring'] ?? array();
776 if ( ! empty( $monitoring['alert_new_admin'] ) ) {
777 $this->send_admin_monitoring_alert(
778 'new_admin',
779 sprintf(
780 /* translators: 1: Username, 2: Email */
781 __( 'New administrator account created: "%1$s" (%2$s)', 'vigilante' ),
782 $user->user_login,
783 $user->user_email
784 ),
785 array(
786 'user_id' => $user_id,
787 'username' => $user->user_login,
788 'email' => $user->user_email,
789 )
790 );
791 }
792 }
793 }
794
795 /**
796 * Log user deletion
797 *
798 * @param int $user_id User ID.
799 */
800 public function log_user_delete( $user_id ) {
801 if ( ! $this->activity_log ) {
802 return;
803 }
804
805 $user = get_userdata( $user_id );
806
807 if ( $user ) {
808 $this->activity_log->log(
809 'user',
810 'deleted',
811 sprintf(
812 /* translators: %s: Username */
813 __( 'User deleted: %s', 'vigilante' ),
814 $user->user_login
815 ),
816 array(
817 'user_id' => $user_id,
818 'email' => $user->user_email,
819 'role' => implode( ', ', $user->roles ),
820 ),
821 'warning'
822 );
823 }
824 }
825
826 /**
827 * Log role change and check for permission elevation
828 *
829 * @param int $user_id User ID.
830 * @param string $new_role New role.
831 * @param array $old_roles Old roles.
832 */
833 public function log_role_change( $user_id, $new_role, $old_roles ) {
834 // Skip if this is initial role assignment during user creation
835 // (already logged by log_user_register, old_roles is empty for new users)
836 if ( empty( $old_roles ) ) {
837 return;
838 }
839
840 $user = get_userdata( $user_id );
841 $was_admin = in_array( 'administrator', $old_roles, true );
842 $is_now_admin = 'administrator' === $new_role;
843 $elevated_to_admin = ! $was_admin && $is_now_admin;
844
845 // Log the change (always warning for role changes)
846 if ( $this->activity_log ) {
847 $this->activity_log->log(
848 'user',
849 'role_changed',
850 sprintf(
851 /* translators: 1: Username, 2: Old role, 3: New role */
852 __( 'User role changed for %1$s: %2$s → %3$s', 'vigilante' ),
853 $user->user_login,
854 implode( ', ', $old_roles ),
855 $new_role
856 ),
857 array(
858 'user_id' => $user_id,
859 'old_roles' => $old_roles,
860 'new_role' => $new_role,
861 ),
862 'warning'
863 );
864 }
865
866 // Send alert for permission elevation if enabled
867 if ( $elevated_to_admin ) {
868 $monitoring = $this->options['admin_monitoring'] ?? array();
869 if ( ! empty( $monitoring['alert_permission_elevation'] ) ) {
870 $this->send_admin_monitoring_alert(
871 'permission_elevation',
872 sprintf(
873 /* translators: 1: Username, 2: Old role */
874 __( 'User "%1$s" elevated to administrator (was: %2$s)', 'vigilante' ),
875 $user->user_login,
876 implode( ', ', $old_roles )
877 ),
878 array(
879 'user_id' => $user_id,
880 'username' => $user->user_login,
881 'email' => $user->user_email,
882 'old_roles' => $old_roles,
883 'new_role' => $new_role,
884 )
885 );
886 }
887 }
888 }
889
890 /**
891 * Send admin monitoring alert email
892 *
893 * @param string $alert_type Alert type identifier.
894 * @param string $message Alert message.
895 * @param array $data Additional data.
896 */
897 private function send_admin_monitoring_alert( $alert_type, $message, $data = array() ) {
898 // Use centralized notification recipients
899 $recipients = Vigilante_Email_Template::get_admin_recipients();
900
901 if ( empty( $recipients ) ) {
902 return;
903 }
904
905 $site_name = get_bloginfo( 'name' );
906 $site_url = home_url();
907
908 // Build subject based on alert type
909 $subjects = array(
910 'new_admin' => __( '[Security Alert] New administrator created', 'vigilante' ),
911 'admin_email_change' => __( '[Security Alert] Administrator email changed', 'vigilante' ),
912 'permission_elevation' => __( '[Security Alert] User elevated to administrator', 'vigilante' ),
913 'admin_password_change' => __( '[Security Alert] Administrator password changed', 'vigilante' ),
914 );
915
916 $subject = isset( $subjects[ $alert_type ] )
917 ? $subjects[ $alert_type ] . ' - ' . $site_name
918 : __( '[Security Alert]', 'vigilante' ) . ' - ' . $site_name;
919
920 // Build email body
921 $body = Vigilante_Email_Template::alert_box( $message );
922
923 $table_data = array(
924 __( 'Site', 'vigilante' ) => $site_url,
925 __( 'Time', 'vigilante' ) => wp_date( 'Y-m-d H:i:s' ),
926 );
927 if ( ! empty( $data['username'] ) ) {
928 $table_data[ __( 'Username', 'vigilante' ) ] = $data['username'];
929 }
930 if ( ! empty( $data['email'] ) ) {
931 $table_data[ __( 'Email', 'vigilante' ) ] = $data['email'];
932 }
933 $current_user = wp_get_current_user();
934 if ( $current_user && $current_user->ID ) {
935 $table_data[ __( 'Changed by', 'vigilante' ) ] = $current_user->user_login;
936 }
937 $body .= Vigilante_Email_Template::data_table( $table_data );
938 $body .= Vigilante_Email_Template::warning_box( __( 'If you did not make this change, please review your site security immediately.', 'vigilante' ) );
939
940 Vigilante_Email_Template::send( $recipients, $subject, __( 'Security alert', 'vigilante' ), $body, true );
941 }
942
943 /**
944 * Get list of insecure usernames
945 *
946 * @return array
947 */
948 public function get_insecure_usernames() {
949 return $this->options['insecure_usernames'] ?? array();
950 }
951
952 /**
953 * Check for existing insecure admin users
954 *
955 * @return array Array of insecure admin users.
956 */
957 public function get_insecure_admin_users() {
958 $insecure_users = array();
959 $insecure_usernames = $this->get_insecure_usernames();
960
961 foreach ( $insecure_usernames as $username ) {
962 $user = get_user_by( 'login', $username );
963 if ( $user ) {
964 $insecure_users[] = array(
965 'id' => $user->ID,
966 'username' => $user->user_login,
967 'email' => $user->user_email,
968 );
969 }
970 }
971
972 return $insecure_users;
973 }
974
975 // =========================================================================
976 // Force Password Reset - Uses native WordPress password reset flow
977 // =========================================================================
978
979 /**
980 * Force password reset for a single user using native WordPress flow
981 *
982 * Flags the user with vigilante_force_reset_pending so any login attempt
983 * is blocked by check_force_reset_on_login(), destroys all active sessions
984 * to kick the user out if currently logged in, and emails them the standard
985 * WordPress password reset link.
986 *
987 * @param int $user_id User ID.
988 * @param int $reset_by_user_id User ID who initiated the reset.
989 * @return array Result with status and message.
990 */
991 public function force_password_reset( $user_id, $reset_by_user_id = 0 ) {
992 $user = get_userdata( $user_id );
993 if ( ! $user ) {
994 return array(
995 'success' => false,
996 'message' => __( 'User not found.', 'vigilante' ),
997 );
998 }
999
1000 // Flag user FIRST so the authenticate hook blocks any login attempt
1001 // even if the reset key generation or email sending fails midway.
1002 update_user_meta( $user_id, 'vigilante_force_reset_pending', time() );
1003
1004 // Destroy all active sessions so a user that's already logged in is
1005 // kicked out and forced through the reset flow on next request.
1006 $sessions = WP_Session_Tokens::get_instance( $user_id );
1007 $sessions->destroy_all();
1008
1009 // Generate password reset key using WordPress native function.
1010 // IMPORTANT: don't call wp_set_password() afterwards — it would clear
1011 // user_activation_key in the same UPDATE and immediately invalidate
1012 // the key we just stored, breaking the reset link in the email.
1013 $reset_key = get_password_reset_key( $user );
1014
1015 if ( is_wp_error( $reset_key ) ) {
1016 return array(
1017 'success' => false,
1018 'message' => $reset_key->get_error_message(),
1019 );
1020 }
1021
1022 // Send the native WordPress password reset email
1023 $email_sent = $this->send_native_reset_email( $user, $reset_key );
1024
1025 // Log the action
1026 if ( $this->activity_log ) {
1027 $reset_by_user = $reset_by_user_id ? get_userdata( $reset_by_user_id ) : null;
1028 $this->activity_log->log(
1029 'user',
1030 'force_password_reset',
1031 sprintf(
1032 /* translators: 1: Target username, 2: Admin username */
1033 __( 'Password reset forced for user "%1$s" by %2$s', 'vigilante' ),
1034 $user->user_login,
1035 $reset_by_user ? $reset_by_user->user_login : __( 'System', 'vigilante' )
1036 ),
1037 array(
1038 'user_id' => $user_id,
1039 'username' => $user->user_login,
1040 'email' => $user->user_email,
1041 'reset_by' => $reset_by_user_id,
1042 'email_sent' => $email_sent,
1043 ),
1044 'warning'
1045 );
1046 }
1047
1048 return array(
1049 'success' => true,
1050 'email_sent' => $email_sent,
1051 'message' => $email_sent
1052 ? __( 'Password reset email sent.', 'vigilante' )
1053 : __( 'Account flagged for reset but email could not be sent.', 'vigilante' ),
1054 );
1055 }
1056
1057 /**
1058 * Send native WordPress password reset email
1059 *
1060 * @param WP_User $user User object.
1061 * @param string $reset_key Password reset key.
1062 * @return bool Whether email was sent successfully.
1063 */
1064 private function send_native_reset_email( $user, $reset_key ) {
1065 $site_name = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );
1066 $reset_url = network_site_url( "wp-login.php?action=rp&key=$reset_key&login=" . rawurlencode( $user->user_login ), 'login' );
1067
1068 /* translators: %s: User login */
1069 $title = sprintf( __( '[%s] Password Reset', 'vigilante' ), $site_name );
1070
1071 $body = Vigilante_Email_Template::p(
1072 sprintf(
1073 /* translators: %s: Username */
1074 __( 'A site administrator has required a password reset for the account: %s', 'vigilante' ),
1075 $user->user_login
1076 )
1077 );
1078 $body .= Vigilante_Email_Template::info_box( __( 'For security reasons, you need to set a new password.', 'vigilante' ) );
1079 $body .= Vigilante_Email_Template::button( $reset_url, __( 'Reset your password', 'vigilante' ) );
1080
1081 /** This filter is documented in class-user-security.php */
1082 $title = apply_filters( 'vigilante_password_reset_title', $title, $user->user_login, $user );
1083
1084 return Vigilante_Email_Template::send( $user->user_email, $title, __( 'Password reset required', 'vigilante' ), $body );
1085 }
1086
1087 /**
1088 * Force password reset for multiple users
1089 *
1090 * @param array $user_ids Array of user IDs.
1091 * @param int $reset_by_user_id User ID who initiated the reset.
1092 * @return array Results with counts.
1093 */
1094 public function force_password_reset_bulk( $user_ids, $reset_by_user_id = 0 ) {
1095 $results = array(
1096 'success' => 0,
1097 'failed' => 0,
1098 'skipped' => 0,
1099 'emails_sent' => 0,
1100 'total' => count( $user_ids ),
1101 );
1102
1103 foreach ( $user_ids as $user_id ) {
1104 // The caller only proved it holds manage_options, which on a network
1105 // every subsite administrator has. Resetting somebody else's password
1106 // locks them out, so each target is checked one by one. Skipped users
1107 // are counted apart from real failures.
1108 if ( ! current_user_can( 'edit_user', $user_id ) ) {
1109 $results['skipped']++;
1110 continue;
1111 }
1112
1113 $result = $this->force_password_reset( $user_id, $reset_by_user_id );
1114
1115 if ( $result['success'] ) {
1116 $results['success']++;
1117 if ( ! empty( $result['email_sent'] ) ) {
1118 $results['emails_sent']++;
1119 }
1120 } else {
1121 $results['failed']++;
1122 }
1123 }
1124
1125 return $results;
1126 }
1127
1128 /**
1129 * Force password reset for all users
1130 *
1131 * @param int $reset_by_user_id User ID who initiated the reset.
1132 * @param bool $exclude_current Whether to exclude current user.
1133 * @return array Results with counts.
1134 */
1135 public function force_password_reset_all( $reset_by_user_id = 0, $exclude_current = true ) {
1136 $args = array(
1137 'fields' => 'ID',
1138 );
1139
1140 // phpcs:disable WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- Excluding single user is acceptable here.
1141 if ( $exclude_current && $reset_by_user_id ) {
1142 $args['exclude'] = array( $reset_by_user_id );
1143 }
1144 // phpcs:enable WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude
1145
1146 $user_ids = get_users( $args );
1147
1148 return $this->force_password_reset_bulk( $user_ids, $reset_by_user_id );
1149 }
1150
1151 /**
1152 * Force password reset for users with specific roles
1153 *
1154 * @param array $roles Array of role slugs.
1155 * @param int $reset_by_user_id User ID who initiated the reset.
1156 * @param bool $exclude_current Whether to exclude current user.
1157 * @return array Results with counts and affected roles.
1158 */
1159 public function force_password_reset_by_roles( $roles, $reset_by_user_id = 0, $exclude_current = true ) {
1160 if ( empty( $roles ) ) {
1161 return array(
1162 'success' => 0,
1163 'failed' => 0,
1164 'emails_sent' => 0,
1165 'total' => 0,
1166 'roles' => array(),
1167 );
1168 }
1169
1170 $user_ids = array();
1171
1172 foreach ( $roles as $role ) {
1173 $role_users = get_users( array(
1174 'role' => $role,
1175 'fields' => 'ID',
1176 ) );
1177 $user_ids = array_merge( $user_ids, $role_users );
1178 }
1179
1180 // Remove duplicates (users with multiple roles).
1181 $user_ids = array_unique( array_map( 'absint', $user_ids ) );
1182
1183 // phpcs:disable WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- Excluding single user is acceptable here.
1184 if ( $exclude_current && $reset_by_user_id ) {
1185 $user_ids = array_diff( $user_ids, array( $reset_by_user_id ) );
1186 }
1187 // phpcs:enable WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude
1188
1189 $results = $this->force_password_reset_bulk( array_values( $user_ids ), $reset_by_user_id );
1190 $results['roles'] = $roles;
1191
1192 return $results;
1193 }
1194
1195 /**
1196 * Show informative message when a user with a forced reset tries to log in
1197 *
1198 * Hooked to 'authenticate' at priority 30 (after default password check at 20).
1199 * Blocks login while a forced reset is pending REGARDLESS of whether the
1200 * user typed the right password — the admin invalidated the account, not
1201 * just the password, so even valid credentials must not let them in until
1202 * they've gone through the reset link in their email.
1203 *
1204 * @param WP_User|WP_Error|null $user User object, error, or null.
1205 * @param string $username Username or email.
1206 * @param string $password Password.
1207 * @return WP_User|WP_Error|null
1208 */
1209 public function check_force_reset_on_login( $user, $username, $password ) {
1210 // Resolve the target user. The flag must be evaluated whether the
1211 // credentials matched (WP_User) or not (WP_Error).
1212 if ( $user instanceof WP_User ) {
1213 $login_user = $user;
1214 } else {
1215 $login_user = get_user_by( 'login', $username );
1216 if ( ! $login_user ) {
1217 $login_user = get_user_by( 'email', $username );
1218 }
1219 }
1220
1221 if ( ! $login_user ) {
1222 return $user;
1223 }
1224
1225 // Check if this user has a pending forced reset.
1226 $force_reset = get_user_meta( $login_user->ID, 'vigilante_force_reset_pending', true );
1227 if ( ! $force_reset ) {
1228 return $user;
1229 }
1230
1231 // If credentials were wrong with an error other than incorrect_password
1232 // (e.g. a Vigilant lockout, pending approval), don't shadow it.
1233 if ( is_wp_error( $user ) && ! in_array( 'incorrect_password', $user->get_error_codes(), true ) ) {
1234 return $user;
1235 }
1236
1237 // Skip brute force counter for this controlled rejection.
1238 add_filter( 'vigilante_skip_failed_login_count', '__return_true' );
1239
1240 // Surface the controlled rejection in the activity log so the admin
1241 // can tell apart "user fails login because they typed wrong password"
1242 // from "user fails login because we are forcing a reset".
1243 if ( $this->activity_log ) {
1244 $this->activity_log->log(
1245 'login',
1246 'force_reset_login_blocked',
1247 sprintf(
1248 /* translators: %s: Username */
1249 __( 'Login blocked for "%s" — pending forced password reset', 'vigilante' ),
1250 $login_user->user_login
1251 ),
1252 array(
1253 'user_id' => $login_user->ID,
1254 'username' => $login_user->user_login,
1255 ),
1256 'warning'
1257 );
1258 }
1259
1260 return new WP_Error(
1261 'vigilante_force_reset',
1262 __( '<strong>Password reset required:</strong> Your password has been reset by the site administrator for security reasons. Please check your email for a link to set a new password.', 'vigilante' )
1263 );
1264 }
1265
1266 /**
1267 * Clear force reset meta after user successfully resets their password
1268 *
1269 * Hooked to 'after_password_reset'. Also resets password expiration
1270 * tracking — reset_password() doesn't fire profile_update, so without
1271 * this the freshly-reset password may immediately be flagged as expired
1272 * again on next login, creating a redirect loop into profile.php.
1273 *
1274 * @param WP_User $user User object.
1275 */
1276 public function clear_force_reset_meta( $user ) {
1277 if ( ! $user || empty( $user->ID ) ) {
1278 return;
1279 }
1280
1281 delete_user_meta( $user->ID, 'vigilante_force_reset_pending' );
1282 update_user_meta( $user->ID, 'vigilante_password_changed', time() );
1283 delete_user_meta( $user->ID, 'vigilante_must_change_password' );
1284 delete_user_meta( $user->ID, 'vigilante_password_reminder_sent' );
1285 }
1286
1287 // =========================================================================
1288 // Registration Approval - Manual approval for new user registrations
1289 // =========================================================================
1290
1291 /**
1292 * Set new user as pending approval
1293 *
1294 * @param int $user_id User ID.
1295 */
1296 public function set_user_pending_approval( $user_id ) {
1297 $user = get_userdata( $user_id );
1298 if ( ! $user ) {
1299 return;
1300 }
1301
1302 $settings = $this->options['registration_approval'] ?? array();
1303 $affected_roles = $settings['affected_roles'] ?? array( 'subscriber' );
1304
1305 // Check if user role requires approval
1306 $user_roles = $user->roles;
1307 $needs_approval = array_intersect( $user_roles, $affected_roles );
1308
1309 if ( empty( $needs_approval ) ) {
1310 return;
1311 }
1312
1313 // Set pending status
1314 update_user_meta( $user_id, 'vigilante_pending_approval', true );
1315 update_user_meta( $user_id, 'vigilante_pending_since', time() );
1316
1317 // Log
1318 if ( $this->activity_log ) {
1319 $this->activity_log->log(
1320 'user',
1321 'pending_approval',
1322 sprintf(
1323 /* translators: %s: Username */
1324 __( 'New user "%s" awaiting approval', 'vigilante' ),
1325 $user->user_login
1326 ),
1327 array( 'user_id' => $user_id, 'email' => $user->user_email ),
1328 'info'
1329 );
1330 }
1331
1332 // Notify admin
1333 if ( ! empty( $settings['notify_admin'] ) ) {
1334 $this->notify_admin_pending_user( $user );
1335 }
1336 }
1337
1338 /**
1339 * Block pending users from logging in
1340 *
1341 * @param WP_User $user User object.
1342 * @param string $password Password.
1343 * @return WP_User|WP_Error
1344 */
1345 public function block_pending_user_login( $user, $password ) {
1346 if ( is_wp_error( $user ) ) {
1347 return $user;
1348 }
1349
1350 $is_pending = get_user_meta( $user->ID, 'vigilante_pending_approval', true );
1351
1352 if ( $is_pending ) {
1353 // Mark this as a controlled rejection (not a brute force attempt)
1354 add_filter( 'vigilante_skip_failed_login_count', '__return_true' );
1355
1356 return new WP_Error(
1357 'pending_approval',
1358 __( '<strong>Account pending:</strong> Your account is awaiting administrator approval. You will receive an email once approved.', 'vigilante' )
1359 );
1360 }
1361
1362 return $user;
1363 }
1364
1365 /**
1366 * Show admin notice about pending users
1367 */
1368 public function show_pending_users_notice() {
1369 if ( ! current_user_can( 'manage_options' ) ) {
1370 return;
1371 }
1372
1373 $pending_users = $this->get_pending_users();
1374 $count = count( $pending_users );
1375
1376 if ( $count === 0 ) {
1377 return;
1378 }
1379
1380 $screen = get_current_screen();
1381 if ( $screen && 'toplevel_page_vigilante' === $screen->id ) {
1382 return; // Don't show on Vigilante page, shown in UI
1383 }
1384 ?>
1385 <div class="notice notice-warning">
1386 <p>
1387 <?php
1388 printf(
1389 /* translators: 1: Number of users, 2: Link to Vigilante */
1390 esc_html( _n(
1391 '%1$d user is awaiting approval. %2$s',
1392 '%1$d users are awaiting approval. %2$s',
1393 $count,
1394 'vigilante'
1395 ) ),
1396 absint( $count ),
1397 '<a href="' . esc_url( admin_url( 'admin.php?page=vigilante&tab=users#vigilante-section-users-pending' ) ) . '">' . esc_html__( 'Review in Vigilant', 'vigilante' ) . '</a>'
1398 );
1399 ?>
1400 </p>
1401 </div>
1402 <?php
1403 }
1404
1405 /**
1406 * Get pending users
1407 *
1408 * @return array Array of pending user objects.
1409 */
1410 public function get_pending_users() {
1411 // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Limited results in admin context.
1412 $args = array(
1413 'meta_key' => 'vigilante_pending_approval',
1414 'meta_value' => '1',
1415 'orderby' => 'registered',
1416 'order' => 'DESC',
1417 );
1418 // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
1419
1420 return get_users( $args );
1421 }
1422
1423 /**
1424 * Approve a pending user
1425 *
1426 * @param int $user_id User ID.
1427 * @param int $approved_by Admin user ID who approved.
1428 * @return bool
1429 */
1430 public function approve_user( $user_id, $approved_by = 0 ) {
1431 // Same reasoning as reject_user(): approving an account that never asked
1432 // for approval is a no-op that reports success and writes misleading meta.
1433 if ( ! get_user_meta( $user_id, 'vigilante_pending_approval', true ) ) {
1434 return false;
1435 }
1436
1437 $user = get_userdata( $user_id );
1438 if ( ! $user ) {
1439 return false;
1440 }
1441
1442 delete_user_meta( $user_id, 'vigilante_pending_approval' );
1443 delete_user_meta( $user_id, 'vigilante_pending_since' );
1444 update_user_meta( $user_id, 'vigilante_approved_by', $approved_by );
1445 update_user_meta( $user_id, 'vigilante_approved_date', time() );
1446
1447 // Log
1448 if ( $this->activity_log ) {
1449 $admin = $approved_by ? get_userdata( $approved_by ) : null;
1450 $this->activity_log->log(
1451 'user',
1452 'user_approved',
1453 sprintf(
1454 /* translators: 1: Username, 2: Admin username */
1455 __( 'User "%1$s" approved by %2$s', 'vigilante' ),
1456 $user->user_login,
1457 $admin ? $admin->user_login : __( 'System', 'vigilante' )
1458 ),
1459 array( 'user_id' => $user_id, 'approved_by' => $approved_by ),
1460 'info'
1461 );
1462 }
1463
1464 // Send approval email
1465 $this->send_approval_email( $user );
1466
1467 return true;
1468 }
1469
1470 /**
1471 * Reject a pending user
1472 *
1473 * @param int $user_id User ID.
1474 * @param int $rejected_by Admin user ID who rejected.
1475 * @param string $reason Optional rejection reason.
1476 * @return bool
1477 */
1478 public function reject_user( $user_id, $rejected_by = 0, $reason = '' ) {
1479 $user = get_userdata( $user_id );
1480 if ( ! $user ) {
1481 return false;
1482 }
1483
1484 // Only an account actually waiting for approval may be rejected. Without
1485 // this the handler deletes any user id it is given, and wp_delete_user()
1486 // with no reassignment takes their posts with them, skipping the dialog
1487 // core always shows. Deleting a member is the Users screen's job.
1488 if ( ! get_user_meta( $user_id, 'vigilante_pending_approval', true ) ) {
1489 return false;
1490 }
1491
1492 // Log before deletion
1493 if ( $this->activity_log ) {
1494 $admin = $rejected_by ? get_userdata( $rejected_by ) : null;
1495 $this->activity_log->log(
1496 'user',
1497 'user_rejected',
1498 sprintf(
1499 /* translators: 1: Username, 2: Admin username */
1500 __( 'User "%1$s" rejected by %2$s', 'vigilante' ),
1501 $user->user_login,
1502 $admin ? $admin->user_login : __( 'System', 'vigilante' )
1503 ),
1504 array(
1505 'user_id' => $user_id,
1506 'rejected_by' => $rejected_by,
1507 'reason' => $reason,
1508 'email' => $user->user_email,
1509 ),
1510 'warning'
1511 );
1512 }
1513
1514 // Send rejection email before deleting
1515 $this->send_rejection_email( $user, $reason );
1516
1517 // Delete user
1518 require_once ABSPATH . 'wp-admin/includes/user.php';
1519 return wp_delete_user( $user_id );
1520 }
1521
1522 /**
1523 * Notify admin about pending user
1524 *
1525 * @param WP_User $user User object.
1526 */
1527 private function notify_admin_pending_user( $user ) {
1528 $recipients = Vigilante_Email_Template::get_admin_recipients();
1529 $site_name = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );
1530
1531 $subject = sprintf(
1532 /* translators: %s: Site name */
1533 __( '[%s] New user registration pending approval', 'vigilante' ),
1534 $site_name
1535 );
1536
1537 $approve_url = admin_url( 'admin.php?page=vigilante&tab=users#vigilante-section-users-pending' );
1538
1539 $body = Vigilante_Email_Template::p( __( 'A new user has registered and is awaiting your approval.', 'vigilante' ) );
1540 $body .= Vigilante_Email_Template::data_table( array(
1541 __( 'Username', 'vigilante' ) => $user->user_login,
1542 __( 'Email', 'vigilante' ) => $user->user_email,
1543 ) );
1544 $body .= Vigilante_Email_Template::button( $approve_url, __( 'Review registration', 'vigilante' ) );
1545
1546 Vigilante_Email_Template::send( $recipients, $subject, __( 'New registration pending', 'vigilante' ), $body );
1547 }
1548
1549 /**
1550 * Send approval email to user
1551 *
1552 * @param WP_User $user User object.
1553 */
1554 private function send_approval_email( $user ) {
1555 $site_name = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );
1556
1557 // Generate password reset key so user can set their password
1558 $key = get_password_reset_key( $user );
1559 if ( is_wp_error( $key ) ) {
1560 // Fallback to simple login URL if key generation fails
1561 $action_url = wp_login_url();
1562 $action_text = __( 'You can now log in:', 'vigilante' );
1563 } else {
1564 $action_url = network_site_url( "wp-login.php?action=rp&key=$key&login=" . rawurlencode( $user->user_login ), 'login' );
1565 $action_text = __( 'Please set your password by clicking the link below:', 'vigilante' );
1566 }
1567
1568 $subject = sprintf(
1569 /* translators: %s: Site name */
1570 __( '[%s] Your account has been approved', 'vigilante' ),
1571 $site_name
1572 );
1573
1574 $body = Vigilante_Email_Template::success_box(
1575 sprintf(
1576 /* translators: 1: Username, 2: Site name */
1577 __( 'Hello %1$s, great news! Your account on %2$s has been approved.', 'vigilante' ),
1578 $user->display_name,
1579 $site_name
1580 )
1581 );
1582 $body .= Vigilante_Email_Template::p( $action_text );
1583 $body .= Vigilante_Email_Template::button( $action_url, __( 'Set up your account', 'vigilante' ) );
1584
1585 /**
1586 * Filters the approval email message
1587 *
1588 * @param string $body Email HTML body.
1589 * @param WP_User $user User object.
1590 */
1591 $body = apply_filters( 'vigilante_approval_email_message', $body, $user );
1592
1593 Vigilante_Email_Template::send( $user->user_email, $subject, __( 'Account approved', 'vigilante' ), $body );
1594 }
1595
1596 /**
1597 * Send rejection email to user
1598 *
1599 * @param WP_User $user User object.
1600 * @param string $reason Rejection reason.
1601 */
1602 private function send_rejection_email( $user, $reason = '' ) {
1603 $site_name = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );
1604
1605 $subject = sprintf(
1606 /* translators: %s: Site name */
1607 __( '[%s] Your registration was not approved', 'vigilante' ),
1608 $site_name
1609 );
1610
1611 $body = Vigilante_Email_Template::p(
1612 sprintf(
1613 /* translators: 1: Username, 2: Site name */
1614 __( 'Hello %1$s, your registration on %2$s was not approved.', 'vigilante' ),
1615 $user->display_name,
1616 $site_name
1617 )
1618 );
1619
1620 if ( ! empty( $reason ) ) {
1621 $body .= Vigilante_Email_Template::info_box(
1622 sprintf(
1623 /* translators: %s: Reason */
1624 __( 'Reason: %s', 'vigilante' ),
1625 $reason
1626 )
1627 );
1628 }
1629
1630 /**
1631 * Filters the rejection email message
1632 *
1633 * @param string $body Email HTML body.
1634 * @param WP_User $user User object.
1635 * @param string $reason Rejection reason.
1636 */
1637 $body = apply_filters( 'vigilante_rejection_email_message', $body, $user, $reason );
1638
1639 Vigilante_Email_Template::send( $user->user_email, $subject, __( 'Registration not approved', 'vigilante' ), $body );
1640 }
1641
1642 // =========================================================================
1643 // Session Management - View and revoke user sessions
1644 // =========================================================================
1645
1646 /**
1647 * Check if user has sessions with corrupted format (numeric keys instead of hash keys)
1648 *
1649 * @param int $user_id User ID.
1650 * @return bool True if corrupted sessions found.
1651 */
1652 public function has_corrupted_sessions( $user_id ) {
1653 $all_sessions = get_user_meta( $user_id, 'session_tokens', true );
1654
1655 if ( ! is_array( $all_sessions ) || empty( $all_sessions ) ) {
1656 return false;
1657 }
1658
1659 foreach ( $all_sessions as $key => $session ) {
1660 // If any key is numeric or not a valid hash, sessions are corrupted
1661 if ( is_int( $key ) || ! is_string( $key ) || strlen( $key ) < 32 ) {
1662 return true;
1663 }
1664 }
1665
1666 return false;
1667 }
1668
1669 /**
1670 * Get raw session count (including corrupted ones)
1671 *
1672 * @param int $user_id User ID.
1673 * @return int Number of sessions in database.
1674 */
1675 public function get_raw_session_count( $user_id ) {
1676 $all_sessions = get_user_meta( $user_id, 'session_tokens', true );
1677 return is_array( $all_sessions ) ? count( $all_sessions ) : 0;
1678 }
1679
1680 /**
1681 * Get user sessions with details
1682 *
1683 * @param int $user_id User ID.
1684 * @return array Array of sessions with details.
1685 */
1686 public function get_user_sessions( $user_id ) {
1687 // Get sessions directly from user meta to preserve keys
1688 $all_sessions = get_user_meta( $user_id, 'session_tokens', true );
1689
1690 if ( ! is_array( $all_sessions ) || empty( $all_sessions ) ) {
1691 return array();
1692 }
1693
1694 $formatted = array();
1695 foreach ( $all_sessions as $token_hash => $session ) {
1696 // Skip if token_hash is not a valid hash (should be 64 char hex string)
1697 if ( ! is_string( $token_hash ) || strlen( $token_hash ) < 32 ) {
1698 continue;
1699 }
1700
1701 $formatted[] = array(
1702 'token_hash' => $token_hash,
1703 'ip' => $session['ip'] ?? __( 'Unknown', 'vigilante' ),
1704 'ua' => $session['ua'] ?? __( 'Unknown', 'vigilante' ),
1705 'login' => $session['login'] ?? 0,
1706 'expiration' => $session['expiration'] ?? 0,
1707 'browser' => $this->parse_user_agent( $session['ua'] ?? '' ),
1708 'is_current' => $this->is_current_session( $token_hash ),
1709 );
1710 }
1711
1712 return $formatted;
1713 }
1714
1715 /**
1716 * Parse user agent string to get browser info
1717 *
1718 * @param string $ua User agent string.
1719 * @return string Browser name and version.
1720 */
1721 private function parse_user_agent( $ua ) {
1722 if ( empty( $ua ) ) {
1723 return __( 'Unknown browser', 'vigilante' );
1724 }
1725
1726 $browser = __( 'Unknown browser', 'vigilante' );
1727
1728 if ( strpos( $ua, 'Firefox' ) !== false ) {
1729 preg_match( '/Firefox\/([0-9.]+)/', $ua, $matches );
1730 $browser = 'Firefox ' . ( $matches[1] ?? '' );
1731 } elseif ( strpos( $ua, 'Edg/' ) !== false ) {
1732 preg_match( '/Edg\/([0-9.]+)/', $ua, $matches );
1733 $browser = 'Edge ' . ( $matches[1] ?? '' );
1734 } elseif ( strpos( $ua, 'Chrome' ) !== false ) {
1735 preg_match( '/Chrome\/([0-9.]+)/', $ua, $matches );
1736 $browser = 'Chrome ' . ( $matches[1] ?? '' );
1737 } elseif ( strpos( $ua, 'Safari' ) !== false ) {
1738 preg_match( '/Version\/([0-9.]+)/', $ua, $matches );
1739 $browser = 'Safari ' . ( $matches[1] ?? '' );
1740 } elseif ( strpos( $ua, 'MSIE' ) !== false || strpos( $ua, 'Trident' ) !== false ) {
1741 $browser = 'Internet Explorer';
1742 }
1743
1744 // Add OS info
1745 $os = '';
1746 if ( strpos( $ua, 'Windows' ) !== false ) {
1747 $os = 'Windows';
1748 } elseif ( strpos( $ua, 'Mac OS' ) !== false ) {
1749 $os = 'macOS';
1750 } elseif ( strpos( $ua, 'Linux' ) !== false ) {
1751 $os = 'Linux';
1752 } elseif ( strpos( $ua, 'iPhone' ) !== false || strpos( $ua, 'iPad' ) !== false ) {
1753 $os = 'iOS';
1754 } elseif ( strpos( $ua, 'Android' ) !== false ) {
1755 $os = 'Android';
1756 }
1757
1758 return $os ? "$browser ($os)" : $browser;
1759 }
1760
1761 /**
1762 * Check if token is current session
1763 *
1764 * @param string $token_hash Session token hash.
1765 * @return bool
1766 */
1767 private function is_current_session( $token_hash ) {
1768 // Ensure token_hash is a valid string
1769 if ( ! is_string( $token_hash ) || empty( $token_hash ) ) {
1770 return false;
1771 }
1772
1773 $cookie = wp_parse_auth_cookie( '', 'logged_in' );
1774 if ( ! $cookie || empty( $cookie['token'] ) ) {
1775 return false;
1776 }
1777
1778 $current_hash = hash( 'sha256', $cookie['token'] );
1779 return hash_equals( $current_hash, $token_hash );
1780 }
1781
1782 /**
1783 * Revoke a specific session
1784 *
1785 * @param int $user_id User ID.
1786 * @param string $token_hash Session token verifier.
1787 * @return bool
1788 */
1789 public function revoke_session( $user_id, $token_hash ) {
1790 // Check if this is the current user's current session - don't allow revoking it
1791 if ( get_current_user_id() === (int) $user_id ) {
1792 $current_token = wp_get_session_token();
1793 if ( $current_token ) {
1794 $current_verifier = hash( 'sha256', $current_token );
1795 if ( $current_verifier === $token_hash ) {
1796 // Can't revoke your own current session
1797 return false;
1798 }
1799 }
1800 }
1801
1802 // Get sessions directly from user meta - bypass any caching
1803 wp_cache_delete( $user_id, 'user_meta' );
1804 $sessions = get_user_meta( $user_id, 'session_tokens', true );
1805
1806 if ( ! is_array( $sessions ) || ! isset( $sessions[ $token_hash ] ) ) {
1807 return false;
1808 }
1809
1810 // Remove the session
1811 unset( $sessions[ $token_hash ] );
1812
1813 // Save back to user meta
1814 if ( empty( $sessions ) ) {
1815 delete_user_meta( $user_id, 'session_tokens' );
1816 } else {
1817 update_user_meta( $user_id, 'session_tokens', $sessions );
1818 }
1819
1820 // Clear all related caches
1821 wp_cache_delete( $user_id, 'user_meta' );
1822 clean_user_cache( $user_id );
1823
1824 // Log
1825 if ( $this->activity_log ) {
1826 $user = get_userdata( $user_id );
1827 $this->activity_log->log(
1828 'user',
1829 'session_revoked',
1830 sprintf(
1831 /* translators: %s: Username */
1832 __( 'Session revoked for user "%s"', 'vigilante' ),
1833 $user ? $user->user_login : $user_id
1834 ),
1835 array( 'user_id' => $user_id ),
1836 'info'
1837 );
1838 }
1839
1840 return true;
1841 }
1842
1843 /**
1844 * Revoke all sessions except current
1845 *
1846 * @param int $user_id User ID.
1847 * @param bool $include_current Whether to revoke current session too.
1848 * @return int Number of sessions revoked.
1849 */
1850 public function revoke_all_sessions( $user_id, $include_current = false ) {
1851 $manager = WP_Session_Tokens::get_instance( $user_id );
1852 $all_sessions = $manager->get_all();
1853 $count = count( $all_sessions );
1854
1855 if ( $count === 0 ) {
1856 return 0;
1857 }
1858
1859 if ( $include_current ) {
1860 // Delete all sessions using WP native method
1861 $manager->destroy_all();
1862 } else {
1863 // For current user, use destroy_others which preserves current session
1864 if ( get_current_user_id() === $user_id ) {
1865 $current_token = wp_get_session_token();
1866 if ( $current_token ) {
1867 $manager->destroy_others( $current_token );
1868 $count--; // Don't count current session
1869 } else {
1870 // No current token found, destroy all
1871 $manager->destroy_all();
1872 }
1873 } else {
1874 // Admin revoking another user's sessions - destroy all of them
1875 $manager->destroy_all();
1876 }
1877 }
1878
1879 // Log
1880 if ( $this->activity_log && $count > 0 ) {
1881 $user = get_userdata( $user_id );
1882 $this->activity_log->log(
1883 'user',
1884 'all_sessions_revoked',
1885 sprintf(
1886 /* translators: 1: Number of sessions, 2: Username */
1887 __( '%1$d sessions revoked for user "%2$s"', 'vigilante' ),
1888 $count,
1889 $user ? $user->user_login : $user_id
1890 ),
1891 array( 'user_id' => $user_id, 'count' => $count ),
1892 'info'
1893 );
1894 }
1895
1896 return max( 0, $count );
1897 }
1898
1899 /**
1900 * Check session limit before login completes (for block_new behavior)
1901 *
1902 * @param WP_User $user User object.
1903 * @param string $password Password.
1904 * @return WP_User|WP_Error
1905 */
1906 public function check_session_limit_before_login( $user, $password ) {
1907 if ( is_wp_error( $user ) ) {
1908 return $user;
1909 }
1910
1911 $settings = $this->options['session_limits'] ?? array();
1912 $max_sessions = absint( $settings['max_sessions'] ?? 3 );
1913 $exclude_admins = ! empty( $settings['exclude_admins'] );
1914
1915 // Skip admins if excluded
1916 if ( $exclude_admins && user_can( $user, 'administrator' ) ) {
1917 return $user;
1918 }
1919
1920 $sessions = WP_Session_Tokens::get_instance( $user->ID );
1921 $all_sessions = $sessions->get_all();
1922 $session_count = count( $all_sessions );
1923
1924 // Block if already at or over limit
1925 if ( $session_count >= $max_sessions ) {
1926 // Log
1927 if ( $this->activity_log ) {
1928 $this->activity_log->log(
1929 'user',
1930 'session_limit_blocked',
1931 sprintf(
1932 /* translators: 1: Username, 2: Max sessions */
1933 __( 'Login blocked for "%1$s" - too many active sessions (limit: %2$d)', 'vigilante' ),
1934 $user->user_login,
1935 $max_sessions
1936 ),
1937 array( 'user_id' => $user->ID, 'current_sessions' => $session_count, 'limit' => $max_sessions ),
1938 'warning'
1939 );
1940 }
1941
1942 // Mark this as a controlled rejection (not a brute force attempt)
1943 add_filter( 'vigilante_skip_failed_login_count', '__return_true' );
1944
1945 return new WP_Error(
1946 'session_limit_exceeded',
1947 sprintf(
1948 /* translators: %d: Maximum sessions allowed */
1949 __( '<strong>Session limit:</strong> You have too many active sessions (%d). Please log out from another device first, or contact an administrator.', 'vigilante' ),
1950 $max_sessions
1951 )
1952 );
1953 }
1954
1955 return $user;
1956 }
1957
1958 /**
1959 * Enforce session limit on login
1960 *
1961 * @param string $user_login Username.
1962 * @param WP_User $user User object.
1963 */
1964 public function enforce_session_limit( $user_login, $user ) {
1965 $settings = $this->options['session_limits'] ?? array();
1966 $max_sessions = absint( $settings['max_sessions'] ?? 3 );
1967 $behavior = $settings['behavior'] ?? 'block_new';
1968 $exclude_admins = ! empty( $settings['exclude_admins'] );
1969
1970 // Skip admins if excluded
1971 if ( $exclude_admins && user_can( $user, 'administrator' ) ) {
1972 return;
1973 }
1974
1975 $sessions = WP_Session_Tokens::get_instance( $user->ID );
1976 $all_sessions = $sessions->get_all();
1977 $session_count = count( $all_sessions );
1978
1979 // Check if over limit (accounting for the session just created)
1980 if ( $session_count <= $max_sessions ) {
1981 return;
1982 }
1983
1984 if ( 'close_oldest' === $behavior ) {
1985 // Sort by login time and destroy oldest
1986 uasort( $all_sessions, function( $a, $b ) {
1987 return ( $a['login'] ?? 0 ) - ( $b['login'] ?? 0 );
1988 } );
1989
1990 $sessions_to_remove = $session_count - $max_sessions;
1991 $removed = 0;
1992
1993 foreach ( $all_sessions as $token_hash => $session ) {
1994 if ( $removed >= $sessions_to_remove ) {
1995 break;
1996 }
1997 // Don't remove current session
1998 if ( ! $this->is_current_session( $token_hash ) ) {
1999 $sessions->destroy( $token_hash );
2000 $removed++;
2001 }
2002 }
2003
2004 // Log
2005 if ( $this->activity_log && $removed > 0 ) {
2006 $this->activity_log->log(
2007 'user',
2008 'session_limit_enforced',
2009 sprintf(
2010 /* translators: 1: Number of sessions, 2: Username */
2011 __( '%1$d oldest sessions closed for user "%2$s" (session limit: %3$d)', 'vigilante' ),
2012 $removed,
2013 $user->user_login,
2014 $max_sessions
2015 ),
2016 array( 'user_id' => $user->ID, 'removed' => $removed, 'limit' => $max_sessions ),
2017 'info'
2018 );
2019 }
2020 }
2021 // Note: 'block_new' behavior is handled in check_session_limit_before_login
2022 }
2023
2024 // =========================================================================
2025 // Password Expiration - Force password change after X days
2026 // =========================================================================
2027
2028 /**
2029 * Check password expiration on login
2030 *
2031 * @param string $user_login Username.
2032 * @param WP_User $user User object.
2033 */
2034 public function check_password_expiration( $user_login, $user ) {
2035 if ( $this->is_password_expired( $user->ID ) ) {
2036 // Set flag to force password change
2037 update_user_meta( $user->ID, 'vigilante_must_change_password', true );
2038 }
2039 }
2040
2041 /**
2042 * Show password expiration warning notice
2043 */
2044 public function show_password_expiration_notice() {
2045 if ( ! is_user_logged_in() ) {
2046 return;
2047 }
2048
2049 $user_id = get_current_user_id();
2050 $settings = $this->options['password_expiration'] ?? array();
2051
2052 // Honor both affected_roles AND the per-user exclusion list, and
2053 // clear stale flags if the user no longer matches the rules.
2054 if ( ! $this->is_password_expiration_applicable( $user_id ) ) {
2055 // Only on a single site, for the same reason as in
2056 // force_password_change_redirect(): on a network the flag belongs to
2057 // the account, and this site's policy says nothing about the site
2058 // that set it. The 2.11.8 fix only covered that method, and this
2059 // notice cleared the flag anyway on the next admin page; found by
2060 // the cross review of 2.11.8.
2061 if ( ! is_multisite() && get_user_meta( $user_id, 'vigilante_must_change_password', true ) ) {
2062 delete_user_meta( $user_id, 'vigilante_must_change_password' );
2063 }
2064 return;
2065 }
2066
2067 // Check if must change password
2068 $must_change = get_user_meta( $user_id, 'vigilante_must_change_password', true );
2069 if ( $must_change ) {
2070 global $pagenow;
2071 $on_profile = ( 'profile.php' === $pagenow );
2072 ?>
2073 <div class="notice notice-error">
2074 <p>
2075 <strong><?php esc_html_e( 'Password change required', 'vigilante' ); ?></strong>
2076 <?php if ( $on_profile ) : ?>
2077 <?php esc_html_e( 'Your password has expired. Set a new password in the section below and save your profile to continue.', 'vigilante' ); ?>
2078 <?php else : ?>
2079 <?php
2080 printf(
2081 /* translators: %s: Link to profile */
2082 esc_html__( 'Your password has expired. Please %s now.', 'vigilante' ),
2083 '<a href="' . esc_url( admin_url( 'profile.php#password' ) ) . '">' . esc_html__( 'change your password', 'vigilante' ) . '</a>'
2084 );
2085 ?>
2086 <?php endif; ?>
2087 </p>
2088 <?php if ( $on_profile ) : ?>
2089 <p>
2090 <?php esc_html_e( 'Important: your password is only changed once the profile saves with no errors. If any other error is shown above (for example, your display name cannot match your username), fix it as well — otherwise your new password will not be saved and you will keep being asked to change it.', 'vigilante' ); ?>
2091 </p>
2092 <?php endif; ?>
2093 </div>
2094 <?php
2095 return;
2096 }
2097
2098 // Show warning if expiring soon
2099 $days_left = $this->get_days_until_expiration( $user_id );
2100 $warning_days = absint( $settings['warning_days'] ?? 14 );
2101
2102 if ( $days_left > 0 && $days_left <= $warning_days ) {
2103 ?>
2104 <div class="notice notice-warning is-dismissible">
2105 <p>
2106 <?php
2107 printf(
2108 /* translators: 1: Number of days, 2: Link to profile */
2109 esc_html( _n(
2110 'Your password will expire in %1$d day. Please %2$s.',
2111 'Your password will expire in %1$d days. Please %2$s.',
2112 $days_left,
2113 'vigilante'
2114 ) ),
2115 absint( $days_left ),
2116 '<a href="' . esc_url( admin_url( 'profile.php' ) ) . '">' . esc_html__( 'change it now', 'vigilante' ) . '</a>'
2117 );
2118 ?>
2119 </p>
2120 </div>
2121 <?php
2122 }
2123 }
2124
2125 /**
2126 * Force redirect to password change page
2127 */
2128 public function force_password_change_redirect() {
2129 if ( ! is_user_logged_in() ) {
2130 return;
2131 }
2132
2133 // Don't redirect on AJAX or profile page
2134 if ( wp_doing_ajax() ) {
2135 return;
2136 }
2137
2138 global $pagenow;
2139 if ( 'profile.php' === $pagenow ) {
2140 return;
2141 }
2142
2143 $user_id = get_current_user_id();
2144 $must_change = get_user_meta( $user_id, 'vigilante_must_change_password', true );
2145
2146 if ( ! $must_change ) {
2147 return;
2148 }
2149
2150 // Re-validate against current settings: the admin may have removed
2151 // this user's role from affected_roles or added the user to the
2152 // excluded list after the flag was set. Without this check the flag
2153 // outlives the configuration change and locks the user in a redirect
2154 // loop into profile.php.
2155 if ( ! $this->is_password_expiration_applicable( $user_id ) ) {
2156 // On a network the flag is a user meta that every site shares, and the
2157 // policy just checked is only this site's: another site may have set
2158 // it, and clearing it here let a user skip that site's forced change
2159 // by visiting any other dashboard. It is only cleared on a single
2160 // site (2.11.8); on a network the user is just not redirected here.
2161 if ( ! is_multisite() ) {
2162 delete_user_meta( $user_id, 'vigilante_must_change_password' );
2163 }
2164 return;
2165 }
2166
2167 wp_safe_redirect( admin_url( 'profile.php#password' ) );
2168 exit;
2169 }
2170
2171 /**
2172 * Whether password expiration rules currently apply to a given user
2173 *
2174 * Used to detect stale flags after the admin changes affected_roles or
2175 * the per-user exclusion list.
2176 *
2177 * @param int $user_id User ID.
2178 * @return bool
2179 */
2180 private function is_password_expiration_applicable( $user_id ) {
2181 $settings = $this->options['password_expiration'] ?? array();
2182
2183 if ( empty( $settings['enabled'] ) ) {
2184 return false;
2185 }
2186
2187 $affected_roles = $settings['affected_roles'] ?? array( 'administrator', 'editor' );
2188 $excluded_users = array_map( 'absint', $settings['excluded_users'] ?? array() );
2189 $user = get_userdata( $user_id );
2190
2191 if ( ! $user || ! array_intersect( $user->roles, $affected_roles ) ) {
2192 return false;
2193 }
2194
2195 if ( in_array( (int) $user_id, $excluded_users, true ) ) {
2196 return false;
2197 }
2198
2199 return true;
2200 }
2201
2202 /**
2203 * Update password change date when password is changed
2204 *
2205 * @param int $user_id User ID.
2206 * @param WP_User $old_user_data Old user data.
2207 */
2208 public function update_password_change_date( $user_id, $old_user_data ) {
2209 // Check if password was changed
2210 $user = get_userdata( $user_id );
2211 if ( $user->user_pass !== $old_user_data->user_pass ) {
2212 update_user_meta( $user_id, 'vigilante_password_changed', time() );
2213 delete_user_meta( $user_id, 'vigilante_must_change_password' );
2214 delete_user_meta( $user_id, 'vigilante_password_reminder_sent' );
2215
2216 // Store password hash in history
2217 $this->add_password_to_history( $user_id, $user->user_pass );
2218 }
2219 }
2220
2221 /**
2222 * Send password expiry reminder emails (daily cron)
2223 *
2224 * Sends a single reminder per user when they enter the warning period.
2225 * Uses vigilante_password_reminder_sent meta to avoid duplicates.
2226 */
2227 public function send_password_expiry_reminders() {
2228 $settings = $this->options['password_expiration'] ?? array();
2229 $affected_roles = $settings['affected_roles'] ?? array( 'administrator', 'editor' );
2230 $excluded_users = array_map( 'absint', $settings['excluded_users'] ?? array() );
2231 $warning_days = absint( $settings['warning_days'] ?? 14 );
2232
2233 if ( empty( $affected_roles ) ) {
2234 return;
2235 }
2236
2237 $args = array(
2238 'role__in' => $affected_roles,
2239 'fields' => 'ID',
2240 );
2241
2242 if ( ! empty( $excluded_users ) ) {
2243 // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- Small admin-curated exclusion list.
2244 $args['exclude'] = $excluded_users;
2245 }
2246
2247 $users = get_users( $args );
2248
2249 // Asking for IDs only means WordPress never primes the usermeta cache, so
2250 // every get_user_meta() below would hit the database once per user. On a
2251 // site with many users in these roles that is one query per user, every day.
2252 if ( ! empty( $users ) ) {
2253 cache_users( $users );
2254 }
2255
2256 foreach ( $users as $user_id ) {
2257 // Skip if reminder already sent for this cycle
2258 if ( get_user_meta( $user_id, 'vigilante_password_reminder_sent', true ) ) {
2259 continue;
2260 }
2261
2262 $days_left = $this->get_days_until_expiration( $user_id );
2263
2264 // Send when user enters the warning window
2265 if ( $days_left > 0 && $days_left <= $warning_days ) {
2266 $this->send_single_password_reminder( $user_id, $days_left );
2267 update_user_meta( $user_id, 'vigilante_password_reminder_sent', time() );
2268 }
2269 }
2270 }
2271
2272 /**
2273 * Send password expiry reminder to a single user
2274 *
2275 * @param int $user_id User ID.
2276 * @param int $days_left Days until password expires.
2277 */
2278 private function send_single_password_reminder( $user_id, $days_left ) {
2279 $user = get_userdata( $user_id );
2280 if ( ! $user ) {
2281 return;
2282 }
2283
2284 $site_name = get_bloginfo( 'name' );
2285
2286 $subject = sprintf(
2287 /* translators: 1: Site name, 2: Number of days */
2288 __( '[%1$s] Your password expires in %2$d days', 'vigilante' ),
2289 $site_name,
2290 $days_left
2291 );
2292
2293 $body = Vigilante_Email_Template::p(
2294 sprintf(
2295 /* translators: 1: User display name, 2: Number of days */
2296 __( 'Hi %1$s, your password on this site will expire in %2$d days.', 'vigilante' ),
2297 $user->display_name,
2298 $days_left
2299 )
2300 );
2301 $body .= Vigilante_Email_Template::p(
2302 __( 'Please update your password before it expires to avoid any interruptions.', 'vigilante' )
2303 );
2304 $body .= Vigilante_Email_Template::button(
2305 admin_url( 'profile.php#password' ),
2306 __( 'Change your password', 'vigilante' )
2307 );
2308
2309 Vigilante_Email_Template::send( $user->user_email, $subject, __( 'Password expiry reminder', 'vigilante' ), $body );
2310 }
2311
2312 /**
2313 * Check if an admin password was changed and send alert
2314 *
2315 * Hooked independently of password_expiration so monitoring
2316 * works even without expiration enabled.
2317 *
2318 * @param int $user_id User ID.
2319 * @param WP_User $old_user_data Previous user data.
2320 */
2321 public function check_admin_password_change( $user_id, $old_user_data ) {
2322 $user = get_userdata( $user_id );
2323 if ( ! $user || $user->user_pass === $old_user_data->user_pass ) {
2324 return;
2325 }
2326
2327 if ( ! user_can( $user, 'administrator' ) ) {
2328 return;
2329 }
2330
2331 $current_user_id = get_current_user_id();
2332 $changed_by_self = ( $current_user_id === $user_id );
2333
2334 $this->send_admin_monitoring_alert(
2335 'admin_password_change',
2336 $changed_by_self
2337 ? sprintf(
2338 /* translators: %s: Username */
2339 __( 'Administrator "%s" changed their password', 'vigilante' ),
2340 $user->user_login
2341 )
2342 : sprintf(
2343 /* translators: 1: Target username, 2: Actor username */
2344 __( 'Password changed for administrator "%1$s" by "%2$s"', 'vigilante' ),
2345 $user->user_login,
2346 $current_user_id ? get_userdata( $current_user_id )->user_login : __( 'System', 'vigilante' )
2347 ),
2348 array(
2349 'user_id' => $user_id,
2350 'username' => $user->user_login,
2351 'changed_by' => $current_user_id,
2352 'changed_by_self' => $changed_by_self,
2353 )
2354 );
2355 }
2356
2357 /**
2358 * Set initial password change date for new users
2359 *
2360 * @param int $user_id User ID.
2361 */
2362 public function set_initial_password_date( $user_id ) {
2363 update_user_meta( $user_id, 'vigilante_password_changed', time() );
2364 }
2365
2366 /**
2367 * Check if new password is in history
2368 *
2369 * @param WP_Error $errors Error object.
2370 * @param bool $update Whether this is an update.
2371 * @param WP_User $user User object.
2372 */
2373 public function check_password_history( $errors, $update, $user ) {
2374 if ( ! $update || ! isset( $user->ID ) ) {
2375 return;
2376 }
2377
2378 // Read the new password from $user->user_pass (set by WordPress during this
2379 // hook), not from $_POST: no input/nonce sniff and, crucially, no sanitizing
2380 // — wp_check_password() must test the exact string WordPress stores, or the
2381 // reuse check would compare a mangled value and silently miss matches.
2382 if ( ! isset( $user->user_pass ) || '' === $user->user_pass ) {
2383 return;
2384 }
2385
2386 // Profile save that doesn't change the password: user_pass is still the
2387 // stored hash, so there is no new value to compare.
2388 $user_data = get_userdata( $user->ID );
2389 if ( $user_data && $user->user_pass === $user_data->user_pass ) {
2390 return;
2391 }
2392
2393 $new_password = (string) wp_unslash( $user->user_pass );
2394 $settings = $this->options['password_expiration'] ?? array();
2395 $history_count = absint( $settings['password_history'] ?? 3 );
2396
2397 if ( $history_count === 0 ) {
2398 return;
2399 }
2400
2401 $history = get_user_meta( $user->ID, 'vigilante_password_history', true );
2402 if ( ! is_array( $history ) ) {
2403 return;
2404 }
2405
2406 // Check if new password matches any in history
2407 foreach ( array_slice( $history, 0, $history_count ) as $old_hash ) {
2408 if ( wp_check_password( $new_password, $old_hash ) ) {
2409 $errors->add(
2410 'password_reused',
2411 sprintf(
2412 /* translators: %d: Number of passwords */
2413 __( 'You cannot reuse your last %d passwords. Please choose a different password.', 'vigilante' ),
2414 $history_count
2415 )
2416 );
2417 return;
2418 }
2419 }
2420 }
2421
2422 /**
2423 * Add password to history
2424 *
2425 * @param int $user_id User ID.
2426 * @param string $password_hash Password hash.
2427 */
2428 private function add_password_to_history( $user_id, $password_hash ) {
2429 $settings = $this->options['password_expiration'] ?? array();
2430 $history_count = absint( $settings['password_history'] ?? 3 );
2431
2432 if ( $history_count === 0 ) {
2433 return;
2434 }
2435
2436 $history = get_user_meta( $user_id, 'vigilante_password_history', true );
2437 if ( ! is_array( $history ) ) {
2438 $history = array();
2439 }
2440
2441 // Add new password to beginning
2442 array_unshift( $history, $password_hash );
2443
2444 // Keep only the required number
2445 $history = array_slice( $history, 0, $history_count + 1 );
2446
2447 update_user_meta( $user_id, 'vigilante_password_history', $history );
2448 }
2449
2450 /**
2451 * Check if user's password is expired
2452 *
2453 * @param int $user_id User ID.
2454 * @return bool
2455 */
2456 public function is_password_expired( $user_id ) {
2457 $settings = $this->options['password_expiration'] ?? array();
2458
2459 if ( empty( $settings['enabled'] ) ) {
2460 return false;
2461 }
2462
2463 $affected_roles = $settings['affected_roles'] ?? array( 'administrator', 'editor' );
2464 $excluded_users = array_map( 'absint', $settings['excluded_users'] ?? array() );
2465 $user = get_userdata( $user_id );
2466
2467 if ( ! $user || ! array_intersect( $user->roles, $affected_roles ) ) {
2468 return false;
2469 }
2470
2471 if ( in_array( (int) $user_id, $excluded_users, true ) ) {
2472 return false;
2473 }
2474
2475 $expire_days = absint( $settings['expire_days'] ?? 90 );
2476 $last_change = get_user_meta( $user_id, 'vigilante_password_changed', true );
2477
2478 // If no record, set it now (first time)
2479 if ( ! $last_change ) {
2480 update_user_meta( $user_id, 'vigilante_password_changed', time() );
2481 return false;
2482 }
2483
2484 $days_since_change = ( time() - $last_change ) / DAY_IN_SECONDS;
2485
2486 return $days_since_change > $expire_days;
2487 }
2488
2489 /**
2490 * Get days until password expires
2491 *
2492 * @param int $user_id User ID.
2493 * @return int Days until expiration, -1 if not applicable.
2494 */
2495 public function get_days_until_expiration( $user_id ) {
2496 $settings = $this->options['password_expiration'] ?? array();
2497
2498 if ( empty( $settings['enabled'] ) ) {
2499 return -1;
2500 }
2501
2502 $affected_roles = $settings['affected_roles'] ?? array( 'administrator', 'editor' );
2503 $excluded_users = array_map( 'absint', $settings['excluded_users'] ?? array() );
2504 $user = get_userdata( $user_id );
2505
2506 if ( ! $user || ! array_intersect( $user->roles, $affected_roles ) ) {
2507 return -1;
2508 }
2509
2510 if ( in_array( (int) $user_id, $excluded_users, true ) ) {
2511 return -1;
2512 }
2513
2514 $expire_days = absint( $settings['expire_days'] ?? 90 );
2515 $last_change = get_user_meta( $user_id, 'vigilante_password_changed', true );
2516
2517 if ( ! $last_change ) {
2518 return $expire_days;
2519 }
2520
2521 $days_since_change = ( time() - $last_change ) / DAY_IN_SECONDS;
2522 $days_left = $expire_days - $days_since_change;
2523
2524 return max( 0, floor( $days_left ) );
2525 }
2526
2527 // =========================================================================
2528 // Email Verification - Require email verification before login
2529 // =========================================================================
2530
2531 /**
2532 * Send verification email to new user
2533 *
2534 * @param int $user_id User ID.
2535 */
2536 public function send_verification_email( $user_id ) {
2537 $user = get_userdata( $user_id );
2538 if ( ! $user ) {
2539 return;
2540 }
2541
2542 // Generate verification token
2543 $token = wp_generate_password( 32, false );
2544 $token_hash = wp_hash( $token );
2545
2546 $settings = $this->options['email_verification'] ?? array();
2547 $expiry_hours = absint( $settings['token_expiry_hours'] ?? 24 );
2548 $expires = time() + ( $expiry_hours * HOUR_IN_SECONDS );
2549
2550 // Store token
2551 update_user_meta( $user_id, 'vigilante_verification_token', $token_hash );
2552 update_user_meta( $user_id, 'vigilante_verification_expires', $expires );
2553 update_user_meta( $user_id, 'vigilante_email_verified', false );
2554
2555 // Build verification URL
2556 $verify_url = add_query_arg(
2557 array(
2558 'vigilante_verify' => '1',
2559 'user_id' => $user_id,
2560 'token' => $token,
2561 ),
2562 wp_login_url()
2563 );
2564
2565 $site_name = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );
2566
2567 $subject = sprintf(
2568 /* translators: %s: Site name */
2569 __( '[%s] Please verify your email address', 'vigilante' ),
2570 $site_name
2571 );
2572
2573 $body = Vigilante_Email_Template::p(
2574 sprintf(
2575 /* translators: 1: Username, 2: Site name */
2576 __( 'Hello %1$s, thank you for registering on %2$s.', 'vigilante' ),
2577 $user->display_name,
2578 $site_name
2579 )
2580 );
2581 $body .= Vigilante_Email_Template::p( __( 'Please verify your email address by clicking the button below.', 'vigilante' ) );
2582 $body .= Vigilante_Email_Template::button( $verify_url, __( 'Verify email address', 'vigilante' ) );
2583 $body .= Vigilante_Email_Template::small(
2584 sprintf(
2585 /* translators: %d: Expiry hours */
2586 __( 'This link will expire in %d hours. If you did not create this account, please ignore this email.', 'vigilante' ),
2587 $expiry_hours
2588 )
2589 );
2590
2591 /**
2592 * Filters the verification email body
2593 *
2594 * @param string $body Email HTML body.
2595 * @param WP_User $user User object.
2596 * @param string $verify_url Verification URL.
2597 */
2598 $body = apply_filters( 'vigilante_verification_email_message', $body, $user, $verify_url );
2599
2600 Vigilante_Email_Template::send( $user->user_email, $subject, __( 'Email verification', 'vigilante' ), $body );
2601
2602 // Log
2603 if ( $this->activity_log ) {
2604 $this->activity_log->log(
2605 'user',
2606 'verification_email_sent',
2607 sprintf(
2608 /* translators: %s: Username */
2609 __( 'Verification email sent to user "%s"', 'vigilante' ),
2610 $user->user_login
2611 ),
2612 array( 'user_id' => $user_id, 'email' => $user->user_email ),
2613 'info'
2614 );
2615 }
2616 }
2617
2618 /**
2619 * Block unverified users from logging in
2620 *
2621 * @param WP_User $user User object.
2622 * @param string $password Password.
2623 * @return WP_User|WP_Error
2624 */
2625 public function block_unverified_user_login( $user, $password ) {
2626 if ( is_wp_error( $user ) ) {
2627 return $user;
2628 }
2629
2630 // Check if email is verified
2631 $verified = get_user_meta( $user->ID, 'vigilante_email_verified', true );
2632
2633 // If no meta exists, user was created before this feature - allow
2634 if ( '' === $verified ) {
2635 return $user;
2636 }
2637
2638 if ( ! $verified ) {
2639 $settings = $this->options['email_verification'] ?? array();
2640 $allow_resend = ! empty( $settings['allow_resend'] );
2641
2642 $message = __( '<strong>Email not verified:</strong> Please verify your email address before logging in.', 'vigilante' );
2643
2644 if ( $allow_resend ) {
2645 $resend_url = wp_nonce_url(
2646 add_query_arg(
2647 array(
2648 'vigilante_resend' => '1',
2649 'user_id' => $user->ID,
2650 ),
2651 wp_login_url()
2652 ),
2653 'vigilante_resend_verification_' . $user->ID,
2654 '_vigilante_nonce'
2655 );
2656 $message .= ' <a href="' . esc_url( $resend_url ) . '">' . __( 'Resend verification email', 'vigilante' ) . '</a>';
2657 }
2658
2659 return new WP_Error( 'email_not_verified', $message );
2660 }
2661
2662 return $user;
2663 }
2664
2665 /**
2666 * Handle email verification link
2667 */
2668 public function handle_email_verification() {
2669 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- only checking parameter presence for branching, no data modification.
2670 if ( empty( $_GET['vigilante_verify'] ) ) {
2671 // Check for resend request - user_id is read before wp_verify_nonce()
2672 // because the nonce action is user-specific. Nonce verified immediately after.
2673 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce verified below after extracting user_id for the action string.
2674 if ( ! empty( $_GET['vigilante_resend'] ) && ! empty( $_GET['user_id'] ) ) {
2675 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce verified on the next line using this value.
2676 $user_id = absint( $_GET['user_id'] );
2677
2678 // Verify nonce to prevent CSRF and user-ID probing.
2679 if ( ! isset( $_GET['_vigilante_nonce'] ) ||
2680 ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_vigilante_nonce'] ) ), 'vigilante_resend_verification_' . $user_id ) ) {
2681 wp_safe_redirect( add_query_arg( 'vigilante_message', 'invalid', wp_login_url() ) );
2682 exit;
2683 }
2684
2685 // Rate limiting: allow 1 resend every 5 minutes per user to prevent email spam.
2686 $transient_key = 'vigilante_resend_' . $user_id;
2687 if ( false === get_transient( $transient_key ) ) {
2688 $this->send_verification_email( $user_id );
2689 set_transient( $transient_key, 1, 5 * MINUTE_IN_SECONDS );
2690 }
2691
2692 wp_safe_redirect( add_query_arg( 'vigilante_message', 'resent', wp_login_url() ) );
2693 exit;
2694 }
2695 return;
2696 }
2697
2698 // Email verification uses a cryptographic token instead of a nonce,
2699 // since nonces are session-bound and expire - unsuitable for email links.
2700 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token-based verification below.
2701 $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0;
2702 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token-based verification below.
2703 $token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : '';
2704
2705 if ( ! $user_id || ! $token ) {
2706 wp_safe_redirect( add_query_arg( 'vigilante_message', 'invalid', wp_login_url() ) );
2707 exit;
2708 }
2709
2710 $stored_hash = (string) get_user_meta( $user_id, 'vigilante_verification_token', true );
2711 $expires = (int) get_user_meta( $user_id, 'vigilante_verification_expires', true );
2712
2713 // The token first. Checking the expiry before it answered "expired" for
2714 // any account with no verification pending and "invalid" for one waiting,
2715 // so a wrong link revealed which user ids were waiting (2.11.8). Only the
2716 // holder of the right token learns that it expired.
2717 if ( '' === $stored_hash || ! hash_equals( $stored_hash, wp_hash( $token ) ) ) {
2718 wp_safe_redirect( add_query_arg( 'vigilante_message', 'invalid', wp_login_url() ) );
2719 exit;
2720 }
2721
2722 if ( time() > $expires ) {
2723 wp_safe_redirect( add_query_arg( 'vigilante_message', 'expired', wp_login_url() ) );
2724 exit;
2725 }
2726
2727 // Mark as verified
2728 update_user_meta( $user_id, 'vigilante_email_verified', true );
2729 delete_user_meta( $user_id, 'vigilante_verification_token' );
2730 delete_user_meta( $user_id, 'vigilante_verification_expires' );
2731
2732 $user = get_userdata( $user_id );
2733
2734 // Log
2735 if ( $this->activity_log ) {
2736 $this->activity_log->log(
2737 'user',
2738 'email_verified',
2739 sprintf(
2740 /* translators: %s: Username */
2741 __( 'Email verified for user "%s"', 'vigilante' ),
2742 $user ? $user->user_login : $user_id
2743 ),
2744 array( 'user_id' => $user_id ),
2745 'info'
2746 );
2747 }
2748
2749 // Check if user still needs approval
2750 $is_pending = get_user_meta( $user_id, 'vigilante_pending_approval', true );
2751
2752 if ( $is_pending ) {
2753 // User verified but still pending approval
2754 wp_safe_redirect(
2755 add_query_arg(
2756 array(
2757 'vigilante_registration' => 'verified_pending',
2758 '_vigilante_nonce' => wp_create_nonce( 'vigilante_registration_redirect' ),
2759 ),
2760 wp_login_url()
2761 )
2762 );
2763 exit;
2764 }
2765
2766 // No approval needed - send password setup email
2767 if ( $user ) {
2768 $this->send_password_setup_email( $user );
2769 }
2770
2771 wp_safe_redirect( add_query_arg( 'vigilante_message', 'verified', wp_login_url() ) );
2772 exit;
2773 }
2774
2775 /**
2776 * Show verification message on login page
2777 *
2778 * @param string $message Login message.
2779 * @return string
2780 */
2781 public function show_verification_message( $message ) {
2782 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2783 if ( empty( $_GET['vigilante_message'] ) ) {
2784 return $message;
2785 }
2786
2787 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2788 $status = sanitize_key( $_GET['vigilante_message'] );
2789
2790 switch ( $status ) {
2791 case 'verified':
2792 $message = '<p class="message">' . esc_html__( 'Your email has been verified! Check your inbox for an email with instructions to set your password.', 'vigilante' ) . '</p>';
2793 break;
2794 case 'invalid':
2795 $message = '<p class="message" style="border-left-color: #d63638;">' . esc_html__( 'Invalid verification link.', 'vigilante' ) . '</p>';
2796 break;
2797 case 'expired':
2798 $message = '<p class="message" style="border-left-color: #d63638;">' . esc_html__( 'Verification link has expired. Please request a new one.', 'vigilante' ) . '</p>';
2799 break;
2800 case 'resent':
2801 $message = '<p class="message">' . esc_html__( 'Verification email has been resent. Please check your inbox.', 'vigilante' ) . '</p>';
2802 break;
2803 }
2804
2805 return $message;
2806 }
2807
2808 /**
2809 * Check if user email is verified
2810 *
2811 * @param int $user_id User ID.
2812 * @return bool
2813 */
2814 public function is_email_verified( $user_id ) {
2815 $verified = get_user_meta( $user_id, 'vigilante_email_verified', true );
2816
2817 // If no meta exists, consider verified (old users)
2818 if ( '' === $verified ) {
2819 return true;
2820 }
2821
2822 return (bool) $verified;
2823 }
2824
2825 /* =========================================================================
2826 REGISTRATION FLOW CONTROL
2827 ========================================================================= */
2828
2829 /**
2830 * Suppress WordPress new user notification email when our modules are active.
2831 * We control when the password setup email is sent.
2832 *
2833 * @param array $email Email parameters.
2834 * @param WP_User $user User object.
2835 * @param string $blogname Site name.
2836 * @return array|false Empty array to suppress, or original to send.
2837 */
2838 public function suppress_new_user_email( $email, $user, $blogname ) {
2839 $registration_approval = $this->options['registration_approval'] ?? array();
2840 $email_verification = $this->options['email_verification'] ?? array();
2841
2842 // Check if this user's role requires approval
2843 $needs_approval = false;
2844 if ( ! empty( $registration_approval['enabled'] ) ) {
2845 $affected_roles = $registration_approval['affected_roles'] ?? array( 'subscriber' );
2846 $needs_approval = ! empty( array_intersect( $user->roles, $affected_roles ) );
2847 }
2848
2849 // Check if email verification is enabled
2850 $needs_verification = ! empty( $email_verification['enabled'] );
2851
2852 // Suppress WP email if either module applies to this user
2853 if ( $needs_approval || $needs_verification ) {
2854 // Return false to completely suppress the email
2855 return false;
2856 }
2857
2858 return $email;
2859 }
2860
2861 /**
2862 * Redirect after registration to show appropriate message.
2863 *
2864 * @param string $redirect_to Redirect URL.
2865 * @return string Modified redirect URL.
2866 */
2867 public function custom_registration_redirect( $redirect_to ) {
2868 $registration_approval = $this->options['registration_approval'] ?? array();
2869 $email_verification = $this->options['email_verification'] ?? array();
2870
2871 $approval_enabled = ! empty( $registration_approval['enabled'] );
2872 $verification_enabled = ! empty( $email_verification['enabled'] );
2873
2874 // Determine which message to show
2875 if ( $verification_enabled && $approval_enabled ) {
2876 $message = 'registered_verify_then_approval';
2877 } elseif ( $verification_enabled ) {
2878 $message = 'registered_verify';
2879 } elseif ( $approval_enabled ) {
2880 $message = 'registered_pending';
2881 } else {
2882 return $redirect_to;
2883 }
2884
2885 return add_query_arg(
2886 array(
2887 'vigilante_registration' => $message,
2888 '_vigilante_nonce' => wp_create_nonce( 'vigilante_registration_redirect' ),
2889 ),
2890 wp_login_url()
2891 );
2892 }
2893
2894 /**
2895 * Show registration pending message on login page.
2896 *
2897 * @param string $message Existing message.
2898 * @return string Modified message.
2899 */
2900 public function show_registration_pending_message( $message ) {
2901 // Verify nonce from the registration redirect before processing GET data.
2902 if ( ! isset( $_GET['_vigilante_nonce'] ) ||
2903 ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_vigilante_nonce'] ) ), 'vigilante_registration_redirect' ) ) {
2904 return $message;
2905 }
2906
2907 if ( empty( $_GET['vigilante_registration'] ) ) {
2908 return $message;
2909 }
2910
2911 $status = sanitize_key( $_GET['vigilante_registration'] );
2912
2913 switch ( $status ) {
2914 case 'registered_verify':
2915 $message = '<p class="message">' .
2916 esc_html__( 'Registration complete! Please check your email to verify your address before you can log in.', 'vigilante' ) .
2917 '</p>';
2918 break;
2919
2920 case 'registered_pending':
2921 $message = '<p class="message">' .
2922 esc_html__( 'Registration complete! Your account is pending approval by an administrator. You will receive an email once approved.', 'vigilante' ) .
2923 '</p>';
2924 break;
2925
2926 case 'registered_verify_then_approval':
2927 $message = '<p class="message">' .
2928 esc_html__( 'Registration complete! Please check your email to verify your address. Once verified, your account will be reviewed by an administrator.', 'vigilante' ) .
2929 '</p>';
2930 break;
2931
2932 case 'verified_pending':
2933 $message = '<p class="message">' .
2934 esc_html__( 'Email verified! Your account is now pending approval by an administrator. You will receive an email once approved.', 'vigilante' ) .
2935 '</p>';
2936 break;
2937 }
2938
2939 return $message;
2940 }
2941
2942 /**
2943 * Check if user needs approval (based on role settings).
2944 *
2945 * @param int $user_id User ID.
2946 * @return bool
2947 */
2948 public function user_needs_approval( $user_id ) {
2949 $user = get_userdata( $user_id );
2950 if ( ! $user ) {
2951 return false;
2952 }
2953
2954 $registration_approval = $this->options['registration_approval'] ?? array();
2955 if ( empty( $registration_approval['enabled'] ) ) {
2956 return false;
2957 }
2958
2959 $affected_roles = $registration_approval['affected_roles'] ?? array( 'subscriber' );
2960 return ! empty( array_intersect( $user->roles, $affected_roles ) );
2961 }
2962
2963 /**
2964 * Send password setup email to user.
2965 * This is sent when the user is ready to set their password (after verification/approval).
2966 *
2967 * @param WP_User $user User object.
2968 */
2969 public function send_password_setup_email( $user ) {
2970 $site_name = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );
2971
2972 // Generate password reset key
2973 $key = get_password_reset_key( $user );
2974 if ( is_wp_error( $key ) ) {
2975 return;
2976 }
2977
2978 $reset_url = network_site_url( "wp-login.php?action=rp&key=$key&login=" . rawurlencode( $user->user_login ), 'login' );
2979
2980 $subject = sprintf(
2981 /* translators: %s: Site name */
2982 __( '[%s] Set up your password', 'vigilante' ),
2983 $site_name
2984 );
2985
2986 $body = Vigilante_Email_Template::p(
2987 sprintf(
2988 /* translators: 1: Username, 2: Site name */
2989 __( 'Hello %1$s, your account on %2$s is now active.', 'vigilante' ),
2990 $user->display_name,
2991 $site_name
2992 )
2993 );
2994 $body .= Vigilante_Email_Template::p( __( 'Please set your password by clicking the button below.', 'vigilante' ) );
2995 $body .= Vigilante_Email_Template::button( $reset_url, __( 'Set your password', 'vigilante' ) );
2996 $body .= Vigilante_Email_Template::small( __( 'If you did not create this account, please ignore this email.', 'vigilante' ) );
2997
2998 /**
2999 * Filters the password setup email body
3000 *
3001 * @param string $body Email HTML body.
3002 * @param WP_User $user User object.
3003 * @param string $reset_url Password reset URL.
3004 */
3005 $body = apply_filters( 'vigilante_password_setup_email_message', $body, $user, $reset_url );
3006
3007 Vigilante_Email_Template::send( $user->user_email, $subject, __( 'Set up your password', 'vigilante' ), $body );
3008
3009 // Log
3010 if ( $this->activity_log ) {
3011 $this->activity_log->log(
3012 'user',
3013 'password_setup_email_sent',
3014 sprintf(
3015 /* translators: %s: Username */
3016 __( 'Password setup email sent to user "%s"', 'vigilante' ),
3017 $user->user_login
3018 ),
3019 array( 'user_id' => $user->ID, 'email' => $user->user_email ),
3020 'info'
3021 );
3022 }
3023 }
3024 }