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

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

3,009 lines 113.0 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 if ( get_user_meta( $user_id, 'vigilante_must_change_password', true ) ) {
2056 delete_user_meta( $user_id, 'vigilante_must_change_password' );
2057 }
2058 return;
2059 }
2060
2061 // Check if must change password
2062 $must_change = get_user_meta( $user_id, 'vigilante_must_change_password', true );
2063 if ( $must_change ) {
2064 global $pagenow;
2065 $on_profile = ( 'profile.php' === $pagenow );
2066 ?>
2067 <div class="notice notice-error">
2068 <p>
2069 <strong><?php esc_html_e( 'Password change required', 'vigilante' ); ?></strong>
2070 <?php if ( $on_profile ) : ?>
2071 <?php esc_html_e( 'Your password has expired. Set a new password in the section below and save your profile to continue.', 'vigilante' ); ?>
2072 <?php else : ?>
2073 <?php
2074 printf(
2075 /* translators: %s: Link to profile */
2076 esc_html__( 'Your password has expired. Please %s now.', 'vigilante' ),
2077 '<a href="' . esc_url( admin_url( 'profile.php#password' ) ) . '">' . esc_html__( 'change your password', 'vigilante' ) . '</a>'
2078 );
2079 ?>
2080 <?php endif; ?>
2081 </p>
2082 <?php if ( $on_profile ) : ?>
2083 <p>
2084 <?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' ); ?>
2085 </p>
2086 <?php endif; ?>
2087 </div>
2088 <?php
2089 return;
2090 }
2091
2092 // Show warning if expiring soon
2093 $days_left = $this->get_days_until_expiration( $user_id );
2094 $warning_days = absint( $settings['warning_days'] ?? 14 );
2095
2096 if ( $days_left > 0 && $days_left <= $warning_days ) {
2097 ?>
2098 <div class="notice notice-warning is-dismissible">
2099 <p>
2100 <?php
2101 printf(
2102 /* translators: 1: Number of days, 2: Link to profile */
2103 esc_html( _n(
2104 'Your password will expire in %1$d day. Please %2$s.',
2105 'Your password will expire in %1$d days. Please %2$s.',
2106 $days_left,
2107 'vigilante'
2108 ) ),
2109 absint( $days_left ),
2110 '<a href="' . esc_url( admin_url( 'profile.php' ) ) . '">' . esc_html__( 'change it now', 'vigilante' ) . '</a>'
2111 );
2112 ?>
2113 </p>
2114 </div>
2115 <?php
2116 }
2117 }
2118
2119 /**
2120 * Force redirect to password change page
2121 */
2122 public function force_password_change_redirect() {
2123 if ( ! is_user_logged_in() ) {
2124 return;
2125 }
2126
2127 // Don't redirect on AJAX or profile page
2128 if ( wp_doing_ajax() ) {
2129 return;
2130 }
2131
2132 global $pagenow;
2133 if ( 'profile.php' === $pagenow ) {
2134 return;
2135 }
2136
2137 $user_id = get_current_user_id();
2138 $must_change = get_user_meta( $user_id, 'vigilante_must_change_password', true );
2139
2140 if ( ! $must_change ) {
2141 return;
2142 }
2143
2144 // Re-validate against current settings: the admin may have removed
2145 // this user's role from affected_roles or added the user to the
2146 // excluded list after the flag was set. Without this check the flag
2147 // outlives the configuration change and locks the user in a redirect
2148 // loop into profile.php.
2149 if ( ! $this->is_password_expiration_applicable( $user_id ) ) {
2150 delete_user_meta( $user_id, 'vigilante_must_change_password' );
2151 return;
2152 }
2153
2154 wp_safe_redirect( admin_url( 'profile.php#password' ) );
2155 exit;
2156 }
2157
2158 /**
2159 * Whether password expiration rules currently apply to a given user
2160 *
2161 * Used to detect stale flags after the admin changes affected_roles or
2162 * the per-user exclusion list.
2163 *
2164 * @param int $user_id User ID.
2165 * @return bool
2166 */
2167 private function is_password_expiration_applicable( $user_id ) {
2168 $settings = $this->options['password_expiration'] ?? array();
2169
2170 if ( empty( $settings['enabled'] ) ) {
2171 return false;
2172 }
2173
2174 $affected_roles = $settings['affected_roles'] ?? array( 'administrator', 'editor' );
2175 $excluded_users = array_map( 'absint', $settings['excluded_users'] ?? array() );
2176 $user = get_userdata( $user_id );
2177
2178 if ( ! $user || ! array_intersect( $user->roles, $affected_roles ) ) {
2179 return false;
2180 }
2181
2182 if ( in_array( (int) $user_id, $excluded_users, true ) ) {
2183 return false;
2184 }
2185
2186 return true;
2187 }
2188
2189 /**
2190 * Update password change date when password is changed
2191 *
2192 * @param int $user_id User ID.
2193 * @param WP_User $old_user_data Old user data.
2194 */
2195 public function update_password_change_date( $user_id, $old_user_data ) {
2196 // Check if password was changed
2197 $user = get_userdata( $user_id );
2198 if ( $user->user_pass !== $old_user_data->user_pass ) {
2199 update_user_meta( $user_id, 'vigilante_password_changed', time() );
2200 delete_user_meta( $user_id, 'vigilante_must_change_password' );
2201 delete_user_meta( $user_id, 'vigilante_password_reminder_sent' );
2202
2203 // Store password hash in history
2204 $this->add_password_to_history( $user_id, $user->user_pass );
2205 }
2206 }
2207
2208 /**
2209 * Send password expiry reminder emails (daily cron)
2210 *
2211 * Sends a single reminder per user when they enter the warning period.
2212 * Uses vigilante_password_reminder_sent meta to avoid duplicates.
2213 */
2214 public function send_password_expiry_reminders() {
2215 $settings = $this->options['password_expiration'] ?? array();
2216 $affected_roles = $settings['affected_roles'] ?? array( 'administrator', 'editor' );
2217 $excluded_users = array_map( 'absint', $settings['excluded_users'] ?? array() );
2218 $warning_days = absint( $settings['warning_days'] ?? 14 );
2219
2220 if ( empty( $affected_roles ) ) {
2221 return;
2222 }
2223
2224 $args = array(
2225 'role__in' => $affected_roles,
2226 'fields' => 'ID',
2227 );
2228
2229 if ( ! empty( $excluded_users ) ) {
2230 // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- Small admin-curated exclusion list.
2231 $args['exclude'] = $excluded_users;
2232 }
2233
2234 $users = get_users( $args );
2235
2236 // Asking for IDs only means WordPress never primes the usermeta cache, so
2237 // every get_user_meta() below would hit the database once per user. On a
2238 // site with many users in these roles that is one query per user, every day.
2239 if ( ! empty( $users ) ) {
2240 cache_users( $users );
2241 }
2242
2243 foreach ( $users as $user_id ) {
2244 // Skip if reminder already sent for this cycle
2245 if ( get_user_meta( $user_id, 'vigilante_password_reminder_sent', true ) ) {
2246 continue;
2247 }
2248
2249 $days_left = $this->get_days_until_expiration( $user_id );
2250
2251 // Send when user enters the warning window
2252 if ( $days_left > 0 && $days_left <= $warning_days ) {
2253 $this->send_single_password_reminder( $user_id, $days_left );
2254 update_user_meta( $user_id, 'vigilante_password_reminder_sent', time() );
2255 }
2256 }
2257 }
2258
2259 /**
2260 * Send password expiry reminder to a single user
2261 *
2262 * @param int $user_id User ID.
2263 * @param int $days_left Days until password expires.
2264 */
2265 private function send_single_password_reminder( $user_id, $days_left ) {
2266 $user = get_userdata( $user_id );
2267 if ( ! $user ) {
2268 return;
2269 }
2270
2271 $site_name = get_bloginfo( 'name' );
2272
2273 $subject = sprintf(
2274 /* translators: 1: Site name, 2: Number of days */
2275 __( '[%1$s] Your password expires in %2$d days', 'vigilante' ),
2276 $site_name,
2277 $days_left
2278 );
2279
2280 $body = Vigilante_Email_Template::p(
2281 sprintf(
2282 /* translators: 1: User display name, 2: Number of days */
2283 __( 'Hi %1$s, your password on this site will expire in %2$d days.', 'vigilante' ),
2284 $user->display_name,
2285 $days_left
2286 )
2287 );
2288 $body .= Vigilante_Email_Template::p(
2289 __( 'Please update your password before it expires to avoid any interruptions.', 'vigilante' )
2290 );
2291 $body .= Vigilante_Email_Template::button(
2292 admin_url( 'profile.php#password' ),
2293 __( 'Change your password', 'vigilante' )
2294 );
2295
2296 Vigilante_Email_Template::send( $user->user_email, $subject, __( 'Password expiry reminder', 'vigilante' ), $body );
2297 }
2298
2299 /**
2300 * Check if an admin password was changed and send alert
2301 *
2302 * Hooked independently of password_expiration so monitoring
2303 * works even without expiration enabled.
2304 *
2305 * @param int $user_id User ID.
2306 * @param WP_User $old_user_data Previous user data.
2307 */
2308 public function check_admin_password_change( $user_id, $old_user_data ) {
2309 $user = get_userdata( $user_id );
2310 if ( ! $user || $user->user_pass === $old_user_data->user_pass ) {
2311 return;
2312 }
2313
2314 if ( ! user_can( $user, 'administrator' ) ) {
2315 return;
2316 }
2317
2318 $current_user_id = get_current_user_id();
2319 $changed_by_self = ( $current_user_id === $user_id );
2320
2321 $this->send_admin_monitoring_alert(
2322 'admin_password_change',
2323 $changed_by_self
2324 ? sprintf(
2325 /* translators: %s: Username */
2326 __( 'Administrator "%s" changed their password', 'vigilante' ),
2327 $user->user_login
2328 )
2329 : sprintf(
2330 /* translators: 1: Target username, 2: Actor username */
2331 __( 'Password changed for administrator "%1$s" by "%2$s"', 'vigilante' ),
2332 $user->user_login,
2333 $current_user_id ? get_userdata( $current_user_id )->user_login : __( 'System', 'vigilante' )
2334 ),
2335 array(
2336 'user_id' => $user_id,
2337 'username' => $user->user_login,
2338 'changed_by' => $current_user_id,
2339 'changed_by_self' => $changed_by_self,
2340 )
2341 );
2342 }
2343
2344 /**
2345 * Set initial password change date for new users
2346 *
2347 * @param int $user_id User ID.
2348 */
2349 public function set_initial_password_date( $user_id ) {
2350 update_user_meta( $user_id, 'vigilante_password_changed', time() );
2351 }
2352
2353 /**
2354 * Check if new password is in history
2355 *
2356 * @param WP_Error $errors Error object.
2357 * @param bool $update Whether this is an update.
2358 * @param WP_User $user User object.
2359 */
2360 public function check_password_history( $errors, $update, $user ) {
2361 if ( ! $update || ! isset( $user->ID ) ) {
2362 return;
2363 }
2364
2365 // Read the new password from $user->user_pass (set by WordPress during this
2366 // hook), not from $_POST: no input/nonce sniff and, crucially, no sanitizing
2367 // — wp_check_password() must test the exact string WordPress stores, or the
2368 // reuse check would compare a mangled value and silently miss matches.
2369 if ( ! isset( $user->user_pass ) || '' === $user->user_pass ) {
2370 return;
2371 }
2372
2373 // Profile save that doesn't change the password: user_pass is still the
2374 // stored hash, so there is no new value to compare.
2375 $user_data = get_userdata( $user->ID );
2376 if ( $user_data && $user->user_pass === $user_data->user_pass ) {
2377 return;
2378 }
2379
2380 $new_password = (string) wp_unslash( $user->user_pass );
2381 $settings = $this->options['password_expiration'] ?? array();
2382 $history_count = absint( $settings['password_history'] ?? 3 );
2383
2384 if ( $history_count === 0 ) {
2385 return;
2386 }
2387
2388 $history = get_user_meta( $user->ID, 'vigilante_password_history', true );
2389 if ( ! is_array( $history ) ) {
2390 return;
2391 }
2392
2393 // Check if new password matches any in history
2394 foreach ( array_slice( $history, 0, $history_count ) as $old_hash ) {
2395 if ( wp_check_password( $new_password, $old_hash ) ) {
2396 $errors->add(
2397 'password_reused',
2398 sprintf(
2399 /* translators: %d: Number of passwords */
2400 __( 'You cannot reuse your last %d passwords. Please choose a different password.', 'vigilante' ),
2401 $history_count
2402 )
2403 );
2404 return;
2405 }
2406 }
2407 }
2408
2409 /**
2410 * Add password to history
2411 *
2412 * @param int $user_id User ID.
2413 * @param string $password_hash Password hash.
2414 */
2415 private function add_password_to_history( $user_id, $password_hash ) {
2416 $settings = $this->options['password_expiration'] ?? array();
2417 $history_count = absint( $settings['password_history'] ?? 3 );
2418
2419 if ( $history_count === 0 ) {
2420 return;
2421 }
2422
2423 $history = get_user_meta( $user_id, 'vigilante_password_history', true );
2424 if ( ! is_array( $history ) ) {
2425 $history = array();
2426 }
2427
2428 // Add new password to beginning
2429 array_unshift( $history, $password_hash );
2430
2431 // Keep only the required number
2432 $history = array_slice( $history, 0, $history_count + 1 );
2433
2434 update_user_meta( $user_id, 'vigilante_password_history', $history );
2435 }
2436
2437 /**
2438 * Check if user's password is expired
2439 *
2440 * @param int $user_id User ID.
2441 * @return bool
2442 */
2443 public function is_password_expired( $user_id ) {
2444 $settings = $this->options['password_expiration'] ?? array();
2445
2446 if ( empty( $settings['enabled'] ) ) {
2447 return false;
2448 }
2449
2450 $affected_roles = $settings['affected_roles'] ?? array( 'administrator', 'editor' );
2451 $excluded_users = array_map( 'absint', $settings['excluded_users'] ?? array() );
2452 $user = get_userdata( $user_id );
2453
2454 if ( ! $user || ! array_intersect( $user->roles, $affected_roles ) ) {
2455 return false;
2456 }
2457
2458 if ( in_array( (int) $user_id, $excluded_users, true ) ) {
2459 return false;
2460 }
2461
2462 $expire_days = absint( $settings['expire_days'] ?? 90 );
2463 $last_change = get_user_meta( $user_id, 'vigilante_password_changed', true );
2464
2465 // If no record, set it now (first time)
2466 if ( ! $last_change ) {
2467 update_user_meta( $user_id, 'vigilante_password_changed', time() );
2468 return false;
2469 }
2470
2471 $days_since_change = ( time() - $last_change ) / DAY_IN_SECONDS;
2472
2473 return $days_since_change > $expire_days;
2474 }
2475
2476 /**
2477 * Get days until password expires
2478 *
2479 * @param int $user_id User ID.
2480 * @return int Days until expiration, -1 if not applicable.
2481 */
2482 public function get_days_until_expiration( $user_id ) {
2483 $settings = $this->options['password_expiration'] ?? array();
2484
2485 if ( empty( $settings['enabled'] ) ) {
2486 return -1;
2487 }
2488
2489 $affected_roles = $settings['affected_roles'] ?? array( 'administrator', 'editor' );
2490 $excluded_users = array_map( 'absint', $settings['excluded_users'] ?? array() );
2491 $user = get_userdata( $user_id );
2492
2493 if ( ! $user || ! array_intersect( $user->roles, $affected_roles ) ) {
2494 return -1;
2495 }
2496
2497 if ( in_array( (int) $user_id, $excluded_users, true ) ) {
2498 return -1;
2499 }
2500
2501 $expire_days = absint( $settings['expire_days'] ?? 90 );
2502 $last_change = get_user_meta( $user_id, 'vigilante_password_changed', true );
2503
2504 if ( ! $last_change ) {
2505 return $expire_days;
2506 }
2507
2508 $days_since_change = ( time() - $last_change ) / DAY_IN_SECONDS;
2509 $days_left = $expire_days - $days_since_change;
2510
2511 return max( 0, floor( $days_left ) );
2512 }
2513
2514 // =========================================================================
2515 // Email Verification - Require email verification before login
2516 // =========================================================================
2517
2518 /**
2519 * Send verification email to new user
2520 *
2521 * @param int $user_id User ID.
2522 */
2523 public function send_verification_email( $user_id ) {
2524 $user = get_userdata( $user_id );
2525 if ( ! $user ) {
2526 return;
2527 }
2528
2529 // Generate verification token
2530 $token = wp_generate_password( 32, false );
2531 $token_hash = wp_hash( $token );
2532
2533 $settings = $this->options['email_verification'] ?? array();
2534 $expiry_hours = absint( $settings['token_expiry_hours'] ?? 24 );
2535 $expires = time() + ( $expiry_hours * HOUR_IN_SECONDS );
2536
2537 // Store token
2538 update_user_meta( $user_id, 'vigilante_verification_token', $token_hash );
2539 update_user_meta( $user_id, 'vigilante_verification_expires', $expires );
2540 update_user_meta( $user_id, 'vigilante_email_verified', false );
2541
2542 // Build verification URL
2543 $verify_url = add_query_arg(
2544 array(
2545 'vigilante_verify' => '1',
2546 'user_id' => $user_id,
2547 'token' => $token,
2548 ),
2549 wp_login_url()
2550 );
2551
2552 $site_name = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );
2553
2554 $subject = sprintf(
2555 /* translators: %s: Site name */
2556 __( '[%s] Please verify your email address', 'vigilante' ),
2557 $site_name
2558 );
2559
2560 $body = Vigilante_Email_Template::p(
2561 sprintf(
2562 /* translators: 1: Username, 2: Site name */
2563 __( 'Hello %1$s, thank you for registering on %2$s.', 'vigilante' ),
2564 $user->display_name,
2565 $site_name
2566 )
2567 );
2568 $body .= Vigilante_Email_Template::p( __( 'Please verify your email address by clicking the button below.', 'vigilante' ) );
2569 $body .= Vigilante_Email_Template::button( $verify_url, __( 'Verify email address', 'vigilante' ) );
2570 $body .= Vigilante_Email_Template::small(
2571 sprintf(
2572 /* translators: %d: Expiry hours */
2573 __( 'This link will expire in %d hours. If you did not create this account, please ignore this email.', 'vigilante' ),
2574 $expiry_hours
2575 )
2576 );
2577
2578 /**
2579 * Filters the verification email body
2580 *
2581 * @param string $body Email HTML body.
2582 * @param WP_User $user User object.
2583 * @param string $verify_url Verification URL.
2584 */
2585 $body = apply_filters( 'vigilante_verification_email_message', $body, $user, $verify_url );
2586
2587 Vigilante_Email_Template::send( $user->user_email, $subject, __( 'Email verification', 'vigilante' ), $body );
2588
2589 // Log
2590 if ( $this->activity_log ) {
2591 $this->activity_log->log(
2592 'user',
2593 'verification_email_sent',
2594 sprintf(
2595 /* translators: %s: Username */
2596 __( 'Verification email sent to user "%s"', 'vigilante' ),
2597 $user->user_login
2598 ),
2599 array( 'user_id' => $user_id, 'email' => $user->user_email ),
2600 'info'
2601 );
2602 }
2603 }
2604
2605 /**
2606 * Block unverified users from logging in
2607 *
2608 * @param WP_User $user User object.
2609 * @param string $password Password.
2610 * @return WP_User|WP_Error
2611 */
2612 public function block_unverified_user_login( $user, $password ) {
2613 if ( is_wp_error( $user ) ) {
2614 return $user;
2615 }
2616
2617 // Check if email is verified
2618 $verified = get_user_meta( $user->ID, 'vigilante_email_verified', true );
2619
2620 // If no meta exists, user was created before this feature - allow
2621 if ( '' === $verified ) {
2622 return $user;
2623 }
2624
2625 if ( ! $verified ) {
2626 $settings = $this->options['email_verification'] ?? array();
2627 $allow_resend = ! empty( $settings['allow_resend'] );
2628
2629 $message = __( '<strong>Email not verified:</strong> Please verify your email address before logging in.', 'vigilante' );
2630
2631 if ( $allow_resend ) {
2632 $resend_url = wp_nonce_url(
2633 add_query_arg(
2634 array(
2635 'vigilante_resend' => '1',
2636 'user_id' => $user->ID,
2637 ),
2638 wp_login_url()
2639 ),
2640 'vigilante_resend_verification_' . $user->ID,
2641 '_vigilante_nonce'
2642 );
2643 $message .= ' <a href="' . esc_url( $resend_url ) . '">' . __( 'Resend verification email', 'vigilante' ) . '</a>';
2644 }
2645
2646 return new WP_Error( 'email_not_verified', $message );
2647 }
2648
2649 return $user;
2650 }
2651
2652 /**
2653 * Handle email verification link
2654 */
2655 public function handle_email_verification() {
2656 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- only checking parameter presence for branching, no data modification.
2657 if ( empty( $_GET['vigilante_verify'] ) ) {
2658 // Check for resend request - user_id is read before wp_verify_nonce()
2659 // because the nonce action is user-specific. Nonce verified immediately after.
2660 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce verified below after extracting user_id for the action string.
2661 if ( ! empty( $_GET['vigilante_resend'] ) && ! empty( $_GET['user_id'] ) ) {
2662 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce verified on the next line using this value.
2663 $user_id = absint( $_GET['user_id'] );
2664
2665 // Verify nonce to prevent CSRF and user-ID probing.
2666 if ( ! isset( $_GET['_vigilante_nonce'] ) ||
2667 ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_vigilante_nonce'] ) ), 'vigilante_resend_verification_' . $user_id ) ) {
2668 wp_safe_redirect( add_query_arg( 'vigilante_message', 'invalid', wp_login_url() ) );
2669 exit;
2670 }
2671
2672 // Rate limiting: allow 1 resend every 5 minutes per user to prevent email spam.
2673 $transient_key = 'vigilante_resend_' . $user_id;
2674 if ( false === get_transient( $transient_key ) ) {
2675 $this->send_verification_email( $user_id );
2676 set_transient( $transient_key, 1, 5 * MINUTE_IN_SECONDS );
2677 }
2678
2679 wp_safe_redirect( add_query_arg( 'vigilante_message', 'resent', wp_login_url() ) );
2680 exit;
2681 }
2682 return;
2683 }
2684
2685 // Email verification uses a cryptographic token instead of a nonce,
2686 // since nonces are session-bound and expire - unsuitable for email links.
2687 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token-based verification below.
2688 $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0;
2689 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token-based verification below.
2690 $token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : '';
2691
2692 if ( ! $user_id || ! $token ) {
2693 wp_safe_redirect( add_query_arg( 'vigilante_message', 'invalid', wp_login_url() ) );
2694 exit;
2695 }
2696
2697 $stored_hash = get_user_meta( $user_id, 'vigilante_verification_token', true );
2698 $expires = get_user_meta( $user_id, 'vigilante_verification_expires', true );
2699
2700 // Check expiration
2701 if ( time() > $expires ) {
2702 wp_safe_redirect( add_query_arg( 'vigilante_message', 'expired', wp_login_url() ) );
2703 exit;
2704 }
2705
2706 // Verify token
2707 if ( ! hash_equals( $stored_hash, wp_hash( $token ) ) ) {
2708 wp_safe_redirect( add_query_arg( 'vigilante_message', 'invalid', wp_login_url() ) );
2709 exit;
2710 }
2711
2712 // Mark as verified
2713 update_user_meta( $user_id, 'vigilante_email_verified', true );
2714 delete_user_meta( $user_id, 'vigilante_verification_token' );
2715 delete_user_meta( $user_id, 'vigilante_verification_expires' );
2716
2717 $user = get_userdata( $user_id );
2718
2719 // Log
2720 if ( $this->activity_log ) {
2721 $this->activity_log->log(
2722 'user',
2723 'email_verified',
2724 sprintf(
2725 /* translators: %s: Username */
2726 __( 'Email verified for user "%s"', 'vigilante' ),
2727 $user ? $user->user_login : $user_id
2728 ),
2729 array( 'user_id' => $user_id ),
2730 'info'
2731 );
2732 }
2733
2734 // Check if user still needs approval
2735 $is_pending = get_user_meta( $user_id, 'vigilante_pending_approval', true );
2736
2737 if ( $is_pending ) {
2738 // User verified but still pending approval
2739 wp_safe_redirect(
2740 add_query_arg(
2741 array(
2742 'vigilante_registration' => 'verified_pending',
2743 '_vigilante_nonce' => wp_create_nonce( 'vigilante_registration_redirect' ),
2744 ),
2745 wp_login_url()
2746 )
2747 );
2748 exit;
2749 }
2750
2751 // No approval needed - send password setup email
2752 if ( $user ) {
2753 $this->send_password_setup_email( $user );
2754 }
2755
2756 wp_safe_redirect( add_query_arg( 'vigilante_message', 'verified', wp_login_url() ) );
2757 exit;
2758 }
2759
2760 /**
2761 * Show verification message on login page
2762 *
2763 * @param string $message Login message.
2764 * @return string
2765 */
2766 public function show_verification_message( $message ) {
2767 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2768 if ( empty( $_GET['vigilante_message'] ) ) {
2769 return $message;
2770 }
2771
2772 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2773 $status = sanitize_key( $_GET['vigilante_message'] );
2774
2775 switch ( $status ) {
2776 case 'verified':
2777 $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>';
2778 break;
2779 case 'invalid':
2780 $message = '<p class="message" style="border-left-color: #d63638;">' . esc_html__( 'Invalid verification link.', 'vigilante' ) . '</p>';
2781 break;
2782 case 'expired':
2783 $message = '<p class="message" style="border-left-color: #d63638;">' . esc_html__( 'Verification link has expired. Please request a new one.', 'vigilante' ) . '</p>';
2784 break;
2785 case 'resent':
2786 $message = '<p class="message">' . esc_html__( 'Verification email has been resent. Please check your inbox.', 'vigilante' ) . '</p>';
2787 break;
2788 }
2789
2790 return $message;
2791 }
2792
2793 /**
2794 * Check if user email is verified
2795 *
2796 * @param int $user_id User ID.
2797 * @return bool
2798 */
2799 public function is_email_verified( $user_id ) {
2800 $verified = get_user_meta( $user_id, 'vigilante_email_verified', true );
2801
2802 // If no meta exists, consider verified (old users)
2803 if ( '' === $verified ) {
2804 return true;
2805 }
2806
2807 return (bool) $verified;
2808 }
2809
2810 /* =========================================================================
2811 REGISTRATION FLOW CONTROL
2812 ========================================================================= */
2813
2814 /**
2815 * Suppress WordPress new user notification email when our modules are active.
2816 * We control when the password setup email is sent.
2817 *
2818 * @param array $email Email parameters.
2819 * @param WP_User $user User object.
2820 * @param string $blogname Site name.
2821 * @return array|false Empty array to suppress, or original to send.
2822 */
2823 public function suppress_new_user_email( $email, $user, $blogname ) {
2824 $registration_approval = $this->options['registration_approval'] ?? array();
2825 $email_verification = $this->options['email_verification'] ?? array();
2826
2827 // Check if this user's role requires approval
2828 $needs_approval = false;
2829 if ( ! empty( $registration_approval['enabled'] ) ) {
2830 $affected_roles = $registration_approval['affected_roles'] ?? array( 'subscriber' );
2831 $needs_approval = ! empty( array_intersect( $user->roles, $affected_roles ) );
2832 }
2833
2834 // Check if email verification is enabled
2835 $needs_verification = ! empty( $email_verification['enabled'] );
2836
2837 // Suppress WP email if either module applies to this user
2838 if ( $needs_approval || $needs_verification ) {
2839 // Return false to completely suppress the email
2840 return false;
2841 }
2842
2843 return $email;
2844 }
2845
2846 /**
2847 * Redirect after registration to show appropriate message.
2848 *
2849 * @param string $redirect_to Redirect URL.
2850 * @return string Modified redirect URL.
2851 */
2852 public function custom_registration_redirect( $redirect_to ) {
2853 $registration_approval = $this->options['registration_approval'] ?? array();
2854 $email_verification = $this->options['email_verification'] ?? array();
2855
2856 $approval_enabled = ! empty( $registration_approval['enabled'] );
2857 $verification_enabled = ! empty( $email_verification['enabled'] );
2858
2859 // Determine which message to show
2860 if ( $verification_enabled && $approval_enabled ) {
2861 $message = 'registered_verify_then_approval';
2862 } elseif ( $verification_enabled ) {
2863 $message = 'registered_verify';
2864 } elseif ( $approval_enabled ) {
2865 $message = 'registered_pending';
2866 } else {
2867 return $redirect_to;
2868 }
2869
2870 return add_query_arg(
2871 array(
2872 'vigilante_registration' => $message,
2873 '_vigilante_nonce' => wp_create_nonce( 'vigilante_registration_redirect' ),
2874 ),
2875 wp_login_url()
2876 );
2877 }
2878
2879 /**
2880 * Show registration pending message on login page.
2881 *
2882 * @param string $message Existing message.
2883 * @return string Modified message.
2884 */
2885 public function show_registration_pending_message( $message ) {
2886 // Verify nonce from the registration redirect before processing GET data.
2887 if ( ! isset( $_GET['_vigilante_nonce'] ) ||
2888 ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_vigilante_nonce'] ) ), 'vigilante_registration_redirect' ) ) {
2889 return $message;
2890 }
2891
2892 if ( empty( $_GET['vigilante_registration'] ) ) {
2893 return $message;
2894 }
2895
2896 $status = sanitize_key( $_GET['vigilante_registration'] );
2897
2898 switch ( $status ) {
2899 case 'registered_verify':
2900 $message = '<p class="message">' .
2901 esc_html__( 'Registration complete! Please check your email to verify your address before you can log in.', 'vigilante' ) .
2902 '</p>';
2903 break;
2904
2905 case 'registered_pending':
2906 $message = '<p class="message">' .
2907 esc_html__( 'Registration complete! Your account is pending approval by an administrator. You will receive an email once approved.', 'vigilante' ) .
2908 '</p>';
2909 break;
2910
2911 case 'registered_verify_then_approval':
2912 $message = '<p class="message">' .
2913 esc_html__( 'Registration complete! Please check your email to verify your address. Once verified, your account will be reviewed by an administrator.', 'vigilante' ) .
2914 '</p>';
2915 break;
2916
2917 case 'verified_pending':
2918 $message = '<p class="message">' .
2919 esc_html__( 'Email verified! Your account is now pending approval by an administrator. You will receive an email once approved.', 'vigilante' ) .
2920 '</p>';
2921 break;
2922 }
2923
2924 return $message;
2925 }
2926
2927 /**
2928 * Check if user needs approval (based on role settings).
2929 *
2930 * @param int $user_id User ID.
2931 * @return bool
2932 */
2933 public function user_needs_approval( $user_id ) {
2934 $user = get_userdata( $user_id );
2935 if ( ! $user ) {
2936 return false;
2937 }
2938
2939 $registration_approval = $this->options['registration_approval'] ?? array();
2940 if ( empty( $registration_approval['enabled'] ) ) {
2941 return false;
2942 }
2943
2944 $affected_roles = $registration_approval['affected_roles'] ?? array( 'subscriber' );
2945 return ! empty( array_intersect( $user->roles, $affected_roles ) );
2946 }
2947
2948 /**
2949 * Send password setup email to user.
2950 * This is sent when the user is ready to set their password (after verification/approval).
2951 *
2952 * @param WP_User $user User object.
2953 */
2954 public function send_password_setup_email( $user ) {
2955 $site_name = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );
2956
2957 // Generate password reset key
2958 $key = get_password_reset_key( $user );
2959 if ( is_wp_error( $key ) ) {
2960 return;
2961 }
2962
2963 $reset_url = network_site_url( "wp-login.php?action=rp&key=$key&login=" . rawurlencode( $user->user_login ), 'login' );
2964
2965 $subject = sprintf(
2966 /* translators: %s: Site name */
2967 __( '[%s] Set up your password', 'vigilante' ),
2968 $site_name
2969 );
2970
2971 $body = Vigilante_Email_Template::p(
2972 sprintf(
2973 /* translators: 1: Username, 2: Site name */
2974 __( 'Hello %1$s, your account on %2$s is now active.', 'vigilante' ),
2975 $user->display_name,
2976 $site_name
2977 )
2978 );
2979 $body .= Vigilante_Email_Template::p( __( 'Please set your password by clicking the button below.', 'vigilante' ) );
2980 $body .= Vigilante_Email_Template::button( $reset_url, __( 'Set your password', 'vigilante' ) );
2981 $body .= Vigilante_Email_Template::small( __( 'If you did not create this account, please ignore this email.', 'vigilante' ) );
2982
2983 /**
2984 * Filters the password setup email body
2985 *
2986 * @param string $body Email HTML body.
2987 * @param WP_User $user User object.
2988 * @param string $reset_url Password reset URL.
2989 */
2990 $body = apply_filters( 'vigilante_password_setup_email_message', $body, $user, $reset_url );
2991
2992 Vigilante_Email_Template::send( $user->user_email, $subject, __( 'Set up your password', 'vigilante' ), $body );
2993
2994 // Log
2995 if ( $this->activity_log ) {
2996 $this->activity_log->log(
2997 'user',
2998 'password_setup_email_sent',
2999 sprintf(
3000 /* translators: %s: Username */
3001 __( 'Password setup email sent to user "%s"', 'vigilante' ),
3002 $user->user_login
3003 ),
3004 array( 'user_id' => $user->ID, 'email' => $user->user_email ),
3005 'info'
3006 );
3007 }
3008 }
3009 }