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

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

2,985 lines 111.8 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 'emails_sent' => 0,
1099 'total' => count( $user_ids ),
1100 );
1101
1102 foreach ( $user_ids as $user_id ) {
1103 $result = $this->force_password_reset( $user_id, $reset_by_user_id );
1104
1105 if ( $result['success'] ) {
1106 $results['success']++;
1107 if ( ! empty( $result['email_sent'] ) ) {
1108 $results['emails_sent']++;
1109 }
1110 } else {
1111 $results['failed']++;
1112 }
1113 }
1114
1115 return $results;
1116 }
1117
1118 /**
1119 * Force password reset for all users
1120 *
1121 * @param int $reset_by_user_id User ID who initiated the reset.
1122 * @param bool $exclude_current Whether to exclude current user.
1123 * @return array Results with counts.
1124 */
1125 public function force_password_reset_all( $reset_by_user_id = 0, $exclude_current = true ) {
1126 $args = array(
1127 'fields' => 'ID',
1128 );
1129
1130 // phpcs:disable WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- Excluding single user is acceptable here.
1131 if ( $exclude_current && $reset_by_user_id ) {
1132 $args['exclude'] = array( $reset_by_user_id );
1133 }
1134 // phpcs:enable WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude
1135
1136 $user_ids = get_users( $args );
1137
1138 return $this->force_password_reset_bulk( $user_ids, $reset_by_user_id );
1139 }
1140
1141 /**
1142 * Force password reset for users with specific roles
1143 *
1144 * @param array $roles Array of role slugs.
1145 * @param int $reset_by_user_id User ID who initiated the reset.
1146 * @param bool $exclude_current Whether to exclude current user.
1147 * @return array Results with counts and affected roles.
1148 */
1149 public function force_password_reset_by_roles( $roles, $reset_by_user_id = 0, $exclude_current = true ) {
1150 if ( empty( $roles ) ) {
1151 return array(
1152 'success' => 0,
1153 'failed' => 0,
1154 'emails_sent' => 0,
1155 'total' => 0,
1156 'roles' => array(),
1157 );
1158 }
1159
1160 $user_ids = array();
1161
1162 foreach ( $roles as $role ) {
1163 $role_users = get_users( array(
1164 'role' => $role,
1165 'fields' => 'ID',
1166 ) );
1167 $user_ids = array_merge( $user_ids, $role_users );
1168 }
1169
1170 // Remove duplicates (users with multiple roles).
1171 $user_ids = array_unique( array_map( 'absint', $user_ids ) );
1172
1173 // phpcs:disable WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- Excluding single user is acceptable here.
1174 if ( $exclude_current && $reset_by_user_id ) {
1175 $user_ids = array_diff( $user_ids, array( $reset_by_user_id ) );
1176 }
1177 // phpcs:enable WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude
1178
1179 $results = $this->force_password_reset_bulk( array_values( $user_ids ), $reset_by_user_id );
1180 $results['roles'] = $roles;
1181
1182 return $results;
1183 }
1184
1185 /**
1186 * Show informative message when a user with a forced reset tries to log in
1187 *
1188 * Hooked to 'authenticate' at priority 30 (after default password check at 20).
1189 * Blocks login while a forced reset is pending REGARDLESS of whether the
1190 * user typed the right password — the admin invalidated the account, not
1191 * just the password, so even valid credentials must not let them in until
1192 * they've gone through the reset link in their email.
1193 *
1194 * @param WP_User|WP_Error|null $user User object, error, or null.
1195 * @param string $username Username or email.
1196 * @param string $password Password.
1197 * @return WP_User|WP_Error|null
1198 */
1199 public function check_force_reset_on_login( $user, $username, $password ) {
1200 // Resolve the target user. The flag must be evaluated whether the
1201 // credentials matched (WP_User) or not (WP_Error).
1202 if ( $user instanceof WP_User ) {
1203 $login_user = $user;
1204 } else {
1205 $login_user = get_user_by( 'login', $username );
1206 if ( ! $login_user ) {
1207 $login_user = get_user_by( 'email', $username );
1208 }
1209 }
1210
1211 if ( ! $login_user ) {
1212 return $user;
1213 }
1214
1215 // Check if this user has a pending forced reset.
1216 $force_reset = get_user_meta( $login_user->ID, 'vigilante_force_reset_pending', true );
1217 if ( ! $force_reset ) {
1218 return $user;
1219 }
1220
1221 // If credentials were wrong with an error other than incorrect_password
1222 // (e.g. a Vigilant lockout, pending approval), don't shadow it.
1223 if ( is_wp_error( $user ) && ! in_array( 'incorrect_password', $user->get_error_codes(), true ) ) {
1224 return $user;
1225 }
1226
1227 // Skip brute force counter for this controlled rejection.
1228 add_filter( 'vigilante_skip_failed_login_count', '__return_true' );
1229
1230 // Surface the controlled rejection in the activity log so the admin
1231 // can tell apart "user fails login because they typed wrong password"
1232 // from "user fails login because we are forcing a reset".
1233 if ( $this->activity_log ) {
1234 $this->activity_log->log(
1235 'login',
1236 'force_reset_login_blocked',
1237 sprintf(
1238 /* translators: %s: Username */
1239 __( 'Login blocked for "%s" — pending forced password reset', 'vigilante' ),
1240 $login_user->user_login
1241 ),
1242 array(
1243 'user_id' => $login_user->ID,
1244 'username' => $login_user->user_login,
1245 ),
1246 'warning'
1247 );
1248 }
1249
1250 return new WP_Error(
1251 'vigilante_force_reset',
1252 __( '<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' )
1253 );
1254 }
1255
1256 /**
1257 * Clear force reset meta after user successfully resets their password
1258 *
1259 * Hooked to 'after_password_reset'. Also resets password expiration
1260 * tracking — reset_password() doesn't fire profile_update, so without
1261 * this the freshly-reset password may immediately be flagged as expired
1262 * again on next login, creating a redirect loop into profile.php.
1263 *
1264 * @param WP_User $user User object.
1265 */
1266 public function clear_force_reset_meta( $user ) {
1267 if ( ! $user || empty( $user->ID ) ) {
1268 return;
1269 }
1270
1271 delete_user_meta( $user->ID, 'vigilante_force_reset_pending' );
1272 update_user_meta( $user->ID, 'vigilante_password_changed', time() );
1273 delete_user_meta( $user->ID, 'vigilante_must_change_password' );
1274 delete_user_meta( $user->ID, 'vigilante_password_reminder_sent' );
1275 }
1276
1277 // =========================================================================
1278 // Registration Approval - Manual approval for new user registrations
1279 // =========================================================================
1280
1281 /**
1282 * Set new user as pending approval
1283 *
1284 * @param int $user_id User ID.
1285 */
1286 public function set_user_pending_approval( $user_id ) {
1287 $user = get_userdata( $user_id );
1288 if ( ! $user ) {
1289 return;
1290 }
1291
1292 $settings = $this->options['registration_approval'] ?? array();
1293 $affected_roles = $settings['affected_roles'] ?? array( 'subscriber' );
1294
1295 // Check if user role requires approval
1296 $user_roles = $user->roles;
1297 $needs_approval = array_intersect( $user_roles, $affected_roles );
1298
1299 if ( empty( $needs_approval ) ) {
1300 return;
1301 }
1302
1303 // Set pending status
1304 update_user_meta( $user_id, 'vigilante_pending_approval', true );
1305 update_user_meta( $user_id, 'vigilante_pending_since', time() );
1306
1307 // Log
1308 if ( $this->activity_log ) {
1309 $this->activity_log->log(
1310 'user',
1311 'pending_approval',
1312 sprintf(
1313 /* translators: %s: Username */
1314 __( 'New user "%s" awaiting approval', 'vigilante' ),
1315 $user->user_login
1316 ),
1317 array( 'user_id' => $user_id, 'email' => $user->user_email ),
1318 'info'
1319 );
1320 }
1321
1322 // Notify admin
1323 if ( ! empty( $settings['notify_admin'] ) ) {
1324 $this->notify_admin_pending_user( $user );
1325 }
1326 }
1327
1328 /**
1329 * Block pending users from logging in
1330 *
1331 * @param WP_User $user User object.
1332 * @param string $password Password.
1333 * @return WP_User|WP_Error
1334 */
1335 public function block_pending_user_login( $user, $password ) {
1336 if ( is_wp_error( $user ) ) {
1337 return $user;
1338 }
1339
1340 $is_pending = get_user_meta( $user->ID, 'vigilante_pending_approval', true );
1341
1342 if ( $is_pending ) {
1343 // Mark this as a controlled rejection (not a brute force attempt)
1344 add_filter( 'vigilante_skip_failed_login_count', '__return_true' );
1345
1346 return new WP_Error(
1347 'pending_approval',
1348 __( '<strong>Account pending:</strong> Your account is awaiting administrator approval. You will receive an email once approved.', 'vigilante' )
1349 );
1350 }
1351
1352 return $user;
1353 }
1354
1355 /**
1356 * Show admin notice about pending users
1357 */
1358 public function show_pending_users_notice() {
1359 if ( ! current_user_can( 'manage_options' ) ) {
1360 return;
1361 }
1362
1363 $pending_users = $this->get_pending_users();
1364 $count = count( $pending_users );
1365
1366 if ( $count === 0 ) {
1367 return;
1368 }
1369
1370 $screen = get_current_screen();
1371 if ( $screen && 'toplevel_page_vigilante' === $screen->id ) {
1372 return; // Don't show on Vigilante page, shown in UI
1373 }
1374 ?>
1375 <div class="notice notice-warning">
1376 <p>
1377 <?php
1378 printf(
1379 /* translators: 1: Number of users, 2: Link to Vigilante */
1380 esc_html( _n(
1381 '%1$d user is awaiting approval. %2$s',
1382 '%1$d users are awaiting approval. %2$s',
1383 $count,
1384 'vigilante'
1385 ) ),
1386 absint( $count ),
1387 '<a href="' . esc_url( admin_url( 'admin.php?page=vigilante&tab=users' ) ) . '">' . esc_html__( 'Review in Vigilant', 'vigilante' ) . '</a>'
1388 );
1389 ?>
1390 </p>
1391 </div>
1392 <?php
1393 }
1394
1395 /**
1396 * Get pending users
1397 *
1398 * @return array Array of pending user objects.
1399 */
1400 public function get_pending_users() {
1401 // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Limited results in admin context.
1402 $args = array(
1403 'meta_key' => 'vigilante_pending_approval',
1404 'meta_value' => '1',
1405 'orderby' => 'registered',
1406 'order' => 'DESC',
1407 );
1408 // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
1409
1410 return get_users( $args );
1411 }
1412
1413 /**
1414 * Approve a pending user
1415 *
1416 * @param int $user_id User ID.
1417 * @param int $approved_by Admin user ID who approved.
1418 * @return bool
1419 */
1420 public function approve_user( $user_id, $approved_by = 0 ) {
1421 $user = get_userdata( $user_id );
1422 if ( ! $user ) {
1423 return false;
1424 }
1425
1426 delete_user_meta( $user_id, 'vigilante_pending_approval' );
1427 delete_user_meta( $user_id, 'vigilante_pending_since' );
1428 update_user_meta( $user_id, 'vigilante_approved_by', $approved_by );
1429 update_user_meta( $user_id, 'vigilante_approved_date', time() );
1430
1431 // Log
1432 if ( $this->activity_log ) {
1433 $admin = $approved_by ? get_userdata( $approved_by ) : null;
1434 $this->activity_log->log(
1435 'user',
1436 'user_approved',
1437 sprintf(
1438 /* translators: 1: Username, 2: Admin username */
1439 __( 'User "%1$s" approved by %2$s', 'vigilante' ),
1440 $user->user_login,
1441 $admin ? $admin->user_login : __( 'System', 'vigilante' )
1442 ),
1443 array( 'user_id' => $user_id, 'approved_by' => $approved_by ),
1444 'info'
1445 );
1446 }
1447
1448 // Send approval email
1449 $this->send_approval_email( $user );
1450
1451 return true;
1452 }
1453
1454 /**
1455 * Reject a pending user
1456 *
1457 * @param int $user_id User ID.
1458 * @param int $rejected_by Admin user ID who rejected.
1459 * @param string $reason Optional rejection reason.
1460 * @return bool
1461 */
1462 public function reject_user( $user_id, $rejected_by = 0, $reason = '' ) {
1463 $user = get_userdata( $user_id );
1464 if ( ! $user ) {
1465 return false;
1466 }
1467
1468 // Log before deletion
1469 if ( $this->activity_log ) {
1470 $admin = $rejected_by ? get_userdata( $rejected_by ) : null;
1471 $this->activity_log->log(
1472 'user',
1473 'user_rejected',
1474 sprintf(
1475 /* translators: 1: Username, 2: Admin username */
1476 __( 'User "%1$s" rejected by %2$s', 'vigilante' ),
1477 $user->user_login,
1478 $admin ? $admin->user_login : __( 'System', 'vigilante' )
1479 ),
1480 array(
1481 'user_id' => $user_id,
1482 'rejected_by' => $rejected_by,
1483 'reason' => $reason,
1484 'email' => $user->user_email,
1485 ),
1486 'warning'
1487 );
1488 }
1489
1490 // Send rejection email before deleting
1491 $this->send_rejection_email( $user, $reason );
1492
1493 // Delete user
1494 require_once ABSPATH . 'wp-admin/includes/user.php';
1495 return wp_delete_user( $user_id );
1496 }
1497
1498 /**
1499 * Notify admin about pending user
1500 *
1501 * @param WP_User $user User object.
1502 */
1503 private function notify_admin_pending_user( $user ) {
1504 $recipients = Vigilante_Email_Template::get_admin_recipients();
1505 $site_name = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );
1506
1507 $subject = sprintf(
1508 /* translators: %s: Site name */
1509 __( '[%s] New user registration pending approval', 'vigilante' ),
1510 $site_name
1511 );
1512
1513 $approve_url = admin_url( 'admin.php?page=vigilante&tab=users' );
1514
1515 $body = Vigilante_Email_Template::p( __( 'A new user has registered and is awaiting your approval.', 'vigilante' ) );
1516 $body .= Vigilante_Email_Template::data_table( array(
1517 __( 'Username', 'vigilante' ) => $user->user_login,
1518 __( 'Email', 'vigilante' ) => $user->user_email,
1519 ) );
1520 $body .= Vigilante_Email_Template::button( $approve_url, __( 'Review registration', 'vigilante' ) );
1521
1522 Vigilante_Email_Template::send( $recipients, $subject, __( 'New registration pending', 'vigilante' ), $body );
1523 }
1524
1525 /**
1526 * Send approval email to user
1527 *
1528 * @param WP_User $user User object.
1529 */
1530 private function send_approval_email( $user ) {
1531 $site_name = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );
1532
1533 // Generate password reset key so user can set their password
1534 $key = get_password_reset_key( $user );
1535 if ( is_wp_error( $key ) ) {
1536 // Fallback to simple login URL if key generation fails
1537 $action_url = wp_login_url();
1538 $action_text = __( 'You can now log in:', 'vigilante' );
1539 } else {
1540 $action_url = network_site_url( "wp-login.php?action=rp&key=$key&login=" . rawurlencode( $user->user_login ), 'login' );
1541 $action_text = __( 'Please set your password by clicking the link below:', 'vigilante' );
1542 }
1543
1544 $subject = sprintf(
1545 /* translators: %s: Site name */
1546 __( '[%s] Your account has been approved', 'vigilante' ),
1547 $site_name
1548 );
1549
1550 $body = Vigilante_Email_Template::success_box(
1551 sprintf(
1552 /* translators: 1: Username, 2: Site name */
1553 __( 'Hello %1$s, great news! Your account on %2$s has been approved.', 'vigilante' ),
1554 $user->display_name,
1555 $site_name
1556 )
1557 );
1558 $body .= Vigilante_Email_Template::p( $action_text );
1559 $body .= Vigilante_Email_Template::button( $action_url, __( 'Set up your account', 'vigilante' ) );
1560
1561 /**
1562 * Filters the approval email message
1563 *
1564 * @param string $body Email HTML body.
1565 * @param WP_User $user User object.
1566 */
1567 $body = apply_filters( 'vigilante_approval_email_message', $body, $user );
1568
1569 Vigilante_Email_Template::send( $user->user_email, $subject, __( 'Account approved', 'vigilante' ), $body );
1570 }
1571
1572 /**
1573 * Send rejection email to user
1574 *
1575 * @param WP_User $user User object.
1576 * @param string $reason Rejection reason.
1577 */
1578 private function send_rejection_email( $user, $reason = '' ) {
1579 $site_name = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );
1580
1581 $subject = sprintf(
1582 /* translators: %s: Site name */
1583 __( '[%s] Your registration was not approved', 'vigilante' ),
1584 $site_name
1585 );
1586
1587 $body = Vigilante_Email_Template::p(
1588 sprintf(
1589 /* translators: 1: Username, 2: Site name */
1590 __( 'Hello %1$s, your registration on %2$s was not approved.', 'vigilante' ),
1591 $user->display_name,
1592 $site_name
1593 )
1594 );
1595
1596 if ( ! empty( $reason ) ) {
1597 $body .= Vigilante_Email_Template::info_box(
1598 sprintf(
1599 /* translators: %s: Reason */
1600 __( 'Reason: %s', 'vigilante' ),
1601 $reason
1602 )
1603 );
1604 }
1605
1606 /**
1607 * Filters the rejection email message
1608 *
1609 * @param string $body Email HTML body.
1610 * @param WP_User $user User object.
1611 * @param string $reason Rejection reason.
1612 */
1613 $body = apply_filters( 'vigilante_rejection_email_message', $body, $user, $reason );
1614
1615 Vigilante_Email_Template::send( $user->user_email, $subject, __( 'Registration not approved', 'vigilante' ), $body );
1616 }
1617
1618 // =========================================================================
1619 // Session Management - View and revoke user sessions
1620 // =========================================================================
1621
1622 /**
1623 * Check if user has sessions with corrupted format (numeric keys instead of hash keys)
1624 *
1625 * @param int $user_id User ID.
1626 * @return bool True if corrupted sessions found.
1627 */
1628 public function has_corrupted_sessions( $user_id ) {
1629 $all_sessions = get_user_meta( $user_id, 'session_tokens', true );
1630
1631 if ( ! is_array( $all_sessions ) || empty( $all_sessions ) ) {
1632 return false;
1633 }
1634
1635 foreach ( $all_sessions as $key => $session ) {
1636 // If any key is numeric or not a valid hash, sessions are corrupted
1637 if ( is_int( $key ) || ! is_string( $key ) || strlen( $key ) < 32 ) {
1638 return true;
1639 }
1640 }
1641
1642 return false;
1643 }
1644
1645 /**
1646 * Get raw session count (including corrupted ones)
1647 *
1648 * @param int $user_id User ID.
1649 * @return int Number of sessions in database.
1650 */
1651 public function get_raw_session_count( $user_id ) {
1652 $all_sessions = get_user_meta( $user_id, 'session_tokens', true );
1653 return is_array( $all_sessions ) ? count( $all_sessions ) : 0;
1654 }
1655
1656 /**
1657 * Get user sessions with details
1658 *
1659 * @param int $user_id User ID.
1660 * @return array Array of sessions with details.
1661 */
1662 public function get_user_sessions( $user_id ) {
1663 // Get sessions directly from user meta to preserve keys
1664 $all_sessions = get_user_meta( $user_id, 'session_tokens', true );
1665
1666 if ( ! is_array( $all_sessions ) || empty( $all_sessions ) ) {
1667 return array();
1668 }
1669
1670 $formatted = array();
1671 foreach ( $all_sessions as $token_hash => $session ) {
1672 // Skip if token_hash is not a valid hash (should be 64 char hex string)
1673 if ( ! is_string( $token_hash ) || strlen( $token_hash ) < 32 ) {
1674 continue;
1675 }
1676
1677 $formatted[] = array(
1678 'token_hash' => $token_hash,
1679 'ip' => $session['ip'] ?? __( 'Unknown', 'vigilante' ),
1680 'ua' => $session['ua'] ?? __( 'Unknown', 'vigilante' ),
1681 'login' => $session['login'] ?? 0,
1682 'expiration' => $session['expiration'] ?? 0,
1683 'browser' => $this->parse_user_agent( $session['ua'] ?? '' ),
1684 'is_current' => $this->is_current_session( $token_hash ),
1685 );
1686 }
1687
1688 return $formatted;
1689 }
1690
1691 /**
1692 * Parse user agent string to get browser info
1693 *
1694 * @param string $ua User agent string.
1695 * @return string Browser name and version.
1696 */
1697 private function parse_user_agent( $ua ) {
1698 if ( empty( $ua ) ) {
1699 return __( 'Unknown browser', 'vigilante' );
1700 }
1701
1702 $browser = __( 'Unknown browser', 'vigilante' );
1703
1704 if ( strpos( $ua, 'Firefox' ) !== false ) {
1705 preg_match( '/Firefox\/([0-9.]+)/', $ua, $matches );
1706 $browser = 'Firefox ' . ( $matches[1] ?? '' );
1707 } elseif ( strpos( $ua, 'Edg/' ) !== false ) {
1708 preg_match( '/Edg\/([0-9.]+)/', $ua, $matches );
1709 $browser = 'Edge ' . ( $matches[1] ?? '' );
1710 } elseif ( strpos( $ua, 'Chrome' ) !== false ) {
1711 preg_match( '/Chrome\/([0-9.]+)/', $ua, $matches );
1712 $browser = 'Chrome ' . ( $matches[1] ?? '' );
1713 } elseif ( strpos( $ua, 'Safari' ) !== false ) {
1714 preg_match( '/Version\/([0-9.]+)/', $ua, $matches );
1715 $browser = 'Safari ' . ( $matches[1] ?? '' );
1716 } elseif ( strpos( $ua, 'MSIE' ) !== false || strpos( $ua, 'Trident' ) !== false ) {
1717 $browser = 'Internet Explorer';
1718 }
1719
1720 // Add OS info
1721 $os = '';
1722 if ( strpos( $ua, 'Windows' ) !== false ) {
1723 $os = 'Windows';
1724 } elseif ( strpos( $ua, 'Mac OS' ) !== false ) {
1725 $os = 'macOS';
1726 } elseif ( strpos( $ua, 'Linux' ) !== false ) {
1727 $os = 'Linux';
1728 } elseif ( strpos( $ua, 'iPhone' ) !== false || strpos( $ua, 'iPad' ) !== false ) {
1729 $os = 'iOS';
1730 } elseif ( strpos( $ua, 'Android' ) !== false ) {
1731 $os = 'Android';
1732 }
1733
1734 return $os ? "$browser ($os)" : $browser;
1735 }
1736
1737 /**
1738 * Check if token is current session
1739 *
1740 * @param string $token_hash Session token hash.
1741 * @return bool
1742 */
1743 private function is_current_session( $token_hash ) {
1744 // Ensure token_hash is a valid string
1745 if ( ! is_string( $token_hash ) || empty( $token_hash ) ) {
1746 return false;
1747 }
1748
1749 $cookie = wp_parse_auth_cookie( '', 'logged_in' );
1750 if ( ! $cookie || empty( $cookie['token'] ) ) {
1751 return false;
1752 }
1753
1754 $current_hash = hash( 'sha256', $cookie['token'] );
1755 return hash_equals( $current_hash, $token_hash );
1756 }
1757
1758 /**
1759 * Revoke a specific session
1760 *
1761 * @param int $user_id User ID.
1762 * @param string $token_hash Session token verifier.
1763 * @return bool
1764 */
1765 public function revoke_session( $user_id, $token_hash ) {
1766 // Check if this is the current user's current session - don't allow revoking it
1767 if ( get_current_user_id() === (int) $user_id ) {
1768 $current_token = wp_get_session_token();
1769 if ( $current_token ) {
1770 $current_verifier = hash( 'sha256', $current_token );
1771 if ( $current_verifier === $token_hash ) {
1772 // Can't revoke your own current session
1773 return false;
1774 }
1775 }
1776 }
1777
1778 // Get sessions directly from user meta - bypass any caching
1779 wp_cache_delete( $user_id, 'user_meta' );
1780 $sessions = get_user_meta( $user_id, 'session_tokens', true );
1781
1782 if ( ! is_array( $sessions ) || ! isset( $sessions[ $token_hash ] ) ) {
1783 return false;
1784 }
1785
1786 // Remove the session
1787 unset( $sessions[ $token_hash ] );
1788
1789 // Save back to user meta
1790 if ( empty( $sessions ) ) {
1791 delete_user_meta( $user_id, 'session_tokens' );
1792 } else {
1793 update_user_meta( $user_id, 'session_tokens', $sessions );
1794 }
1795
1796 // Clear all related caches
1797 wp_cache_delete( $user_id, 'user_meta' );
1798 clean_user_cache( $user_id );
1799
1800 // Log
1801 if ( $this->activity_log ) {
1802 $user = get_userdata( $user_id );
1803 $this->activity_log->log(
1804 'user',
1805 'session_revoked',
1806 sprintf(
1807 /* translators: %s: Username */
1808 __( 'Session revoked for user "%s"', 'vigilante' ),
1809 $user ? $user->user_login : $user_id
1810 ),
1811 array( 'user_id' => $user_id ),
1812 'info'
1813 );
1814 }
1815
1816 return true;
1817 }
1818
1819 /**
1820 * Revoke all sessions except current
1821 *
1822 * @param int $user_id User ID.
1823 * @param bool $include_current Whether to revoke current session too.
1824 * @return int Number of sessions revoked.
1825 */
1826 public function revoke_all_sessions( $user_id, $include_current = false ) {
1827 $manager = WP_Session_Tokens::get_instance( $user_id );
1828 $all_sessions = $manager->get_all();
1829 $count = count( $all_sessions );
1830
1831 if ( $count === 0 ) {
1832 return 0;
1833 }
1834
1835 if ( $include_current ) {
1836 // Delete all sessions using WP native method
1837 $manager->destroy_all();
1838 } else {
1839 // For current user, use destroy_others which preserves current session
1840 if ( get_current_user_id() === $user_id ) {
1841 $current_token = wp_get_session_token();
1842 if ( $current_token ) {
1843 $manager->destroy_others( $current_token );
1844 $count--; // Don't count current session
1845 } else {
1846 // No current token found, destroy all
1847 $manager->destroy_all();
1848 }
1849 } else {
1850 // Admin revoking another user's sessions - destroy all of them
1851 $manager->destroy_all();
1852 }
1853 }
1854
1855 // Log
1856 if ( $this->activity_log && $count > 0 ) {
1857 $user = get_userdata( $user_id );
1858 $this->activity_log->log(
1859 'user',
1860 'all_sessions_revoked',
1861 sprintf(
1862 /* translators: 1: Number of sessions, 2: Username */
1863 __( '%1$d sessions revoked for user "%2$s"', 'vigilante' ),
1864 $count,
1865 $user ? $user->user_login : $user_id
1866 ),
1867 array( 'user_id' => $user_id, 'count' => $count ),
1868 'info'
1869 );
1870 }
1871
1872 return max( 0, $count );
1873 }
1874
1875 /**
1876 * Check session limit before login completes (for block_new behavior)
1877 *
1878 * @param WP_User $user User object.
1879 * @param string $password Password.
1880 * @return WP_User|WP_Error
1881 */
1882 public function check_session_limit_before_login( $user, $password ) {
1883 if ( is_wp_error( $user ) ) {
1884 return $user;
1885 }
1886
1887 $settings = $this->options['session_limits'] ?? array();
1888 $max_sessions = absint( $settings['max_sessions'] ?? 3 );
1889 $exclude_admins = ! empty( $settings['exclude_admins'] );
1890
1891 // Skip admins if excluded
1892 if ( $exclude_admins && user_can( $user, 'administrator' ) ) {
1893 return $user;
1894 }
1895
1896 $sessions = WP_Session_Tokens::get_instance( $user->ID );
1897 $all_sessions = $sessions->get_all();
1898 $session_count = count( $all_sessions );
1899
1900 // Block if already at or over limit
1901 if ( $session_count >= $max_sessions ) {
1902 // Log
1903 if ( $this->activity_log ) {
1904 $this->activity_log->log(
1905 'user',
1906 'session_limit_blocked',
1907 sprintf(
1908 /* translators: 1: Username, 2: Max sessions */
1909 __( 'Login blocked for "%1$s" - too many active sessions (limit: %2$d)', 'vigilante' ),
1910 $user->user_login,
1911 $max_sessions
1912 ),
1913 array( 'user_id' => $user->ID, 'current_sessions' => $session_count, 'limit' => $max_sessions ),
1914 'warning'
1915 );
1916 }
1917
1918 // Mark this as a controlled rejection (not a brute force attempt)
1919 add_filter( 'vigilante_skip_failed_login_count', '__return_true' );
1920
1921 return new WP_Error(
1922 'session_limit_exceeded',
1923 sprintf(
1924 /* translators: %d: Maximum sessions allowed */
1925 __( '<strong>Session limit:</strong> You have too many active sessions (%d). Please log out from another device first, or contact an administrator.', 'vigilante' ),
1926 $max_sessions
1927 )
1928 );
1929 }
1930
1931 return $user;
1932 }
1933
1934 /**
1935 * Enforce session limit on login
1936 *
1937 * @param string $user_login Username.
1938 * @param WP_User $user User object.
1939 */
1940 public function enforce_session_limit( $user_login, $user ) {
1941 $settings = $this->options['session_limits'] ?? array();
1942 $max_sessions = absint( $settings['max_sessions'] ?? 3 );
1943 $behavior = $settings['behavior'] ?? 'block_new';
1944 $exclude_admins = ! empty( $settings['exclude_admins'] );
1945
1946 // Skip admins if excluded
1947 if ( $exclude_admins && user_can( $user, 'administrator' ) ) {
1948 return;
1949 }
1950
1951 $sessions = WP_Session_Tokens::get_instance( $user->ID );
1952 $all_sessions = $sessions->get_all();
1953 $session_count = count( $all_sessions );
1954
1955 // Check if over limit (accounting for the session just created)
1956 if ( $session_count <= $max_sessions ) {
1957 return;
1958 }
1959
1960 if ( 'close_oldest' === $behavior ) {
1961 // Sort by login time and destroy oldest
1962 uasort( $all_sessions, function( $a, $b ) {
1963 return ( $a['login'] ?? 0 ) - ( $b['login'] ?? 0 );
1964 } );
1965
1966 $sessions_to_remove = $session_count - $max_sessions;
1967 $removed = 0;
1968
1969 foreach ( $all_sessions as $token_hash => $session ) {
1970 if ( $removed >= $sessions_to_remove ) {
1971 break;
1972 }
1973 // Don't remove current session
1974 if ( ! $this->is_current_session( $token_hash ) ) {
1975 $sessions->destroy( $token_hash );
1976 $removed++;
1977 }
1978 }
1979
1980 // Log
1981 if ( $this->activity_log && $removed > 0 ) {
1982 $this->activity_log->log(
1983 'user',
1984 'session_limit_enforced',
1985 sprintf(
1986 /* translators: 1: Number of sessions, 2: Username */
1987 __( '%1$d oldest sessions closed for user "%2$s" (session limit: %3$d)', 'vigilante' ),
1988 $removed,
1989 $user->user_login,
1990 $max_sessions
1991 ),
1992 array( 'user_id' => $user->ID, 'removed' => $removed, 'limit' => $max_sessions ),
1993 'info'
1994 );
1995 }
1996 }
1997 // Note: 'block_new' behavior is handled in check_session_limit_before_login
1998 }
1999
2000 // =========================================================================
2001 // Password Expiration - Force password change after X days
2002 // =========================================================================
2003
2004 /**
2005 * Check password expiration on login
2006 *
2007 * @param string $user_login Username.
2008 * @param WP_User $user User object.
2009 */
2010 public function check_password_expiration( $user_login, $user ) {
2011 if ( $this->is_password_expired( $user->ID ) ) {
2012 // Set flag to force password change
2013 update_user_meta( $user->ID, 'vigilante_must_change_password', true );
2014 }
2015 }
2016
2017 /**
2018 * Show password expiration warning notice
2019 */
2020 public function show_password_expiration_notice() {
2021 if ( ! is_user_logged_in() ) {
2022 return;
2023 }
2024
2025 $user_id = get_current_user_id();
2026 $settings = $this->options['password_expiration'] ?? array();
2027
2028 // Honor both affected_roles AND the per-user exclusion list, and
2029 // clear stale flags if the user no longer matches the rules.
2030 if ( ! $this->is_password_expiration_applicable( $user_id ) ) {
2031 if ( get_user_meta( $user_id, 'vigilante_must_change_password', true ) ) {
2032 delete_user_meta( $user_id, 'vigilante_must_change_password' );
2033 }
2034 return;
2035 }
2036
2037 // Check if must change password
2038 $must_change = get_user_meta( $user_id, 'vigilante_must_change_password', true );
2039 if ( $must_change ) {
2040 global $pagenow;
2041 $on_profile = ( 'profile.php' === $pagenow );
2042 ?>
2043 <div class="notice notice-error">
2044 <p>
2045 <strong><?php esc_html_e( 'Password change required', 'vigilante' ); ?></strong>
2046 <?php if ( $on_profile ) : ?>
2047 <?php esc_html_e( 'Your password has expired. Set a new password in the section below and save your profile to continue.', 'vigilante' ); ?>
2048 <?php else : ?>
2049 <?php
2050 printf(
2051 /* translators: %s: Link to profile */
2052 esc_html__( 'Your password has expired. Please %s now.', 'vigilante' ),
2053 '<a href="' . esc_url( admin_url( 'profile.php#password' ) ) . '">' . esc_html__( 'change your password', 'vigilante' ) . '</a>'
2054 );
2055 ?>
2056 <?php endif; ?>
2057 </p>
2058 <?php if ( $on_profile ) : ?>
2059 <p>
2060 <?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' ); ?>
2061 </p>
2062 <?php endif; ?>
2063 </div>
2064 <?php
2065 return;
2066 }
2067
2068 // Show warning if expiring soon
2069 $days_left = $this->get_days_until_expiration( $user_id );
2070 $warning_days = absint( $settings['warning_days'] ?? 14 );
2071
2072 if ( $days_left > 0 && $days_left <= $warning_days ) {
2073 ?>
2074 <div class="notice notice-warning is-dismissible">
2075 <p>
2076 <?php
2077 printf(
2078 /* translators: 1: Number of days, 2: Link to profile */
2079 esc_html( _n(
2080 'Your password will expire in %1$d day. Please %2$s.',
2081 'Your password will expire in %1$d days. Please %2$s.',
2082 $days_left,
2083 'vigilante'
2084 ) ),
2085 absint( $days_left ),
2086 '<a href="' . esc_url( admin_url( 'profile.php' ) ) . '">' . esc_html__( 'change it now', 'vigilante' ) . '</a>'
2087 );
2088 ?>
2089 </p>
2090 </div>
2091 <?php
2092 }
2093 }
2094
2095 /**
2096 * Force redirect to password change page
2097 */
2098 public function force_password_change_redirect() {
2099 if ( ! is_user_logged_in() ) {
2100 return;
2101 }
2102
2103 // Don't redirect on AJAX or profile page
2104 if ( wp_doing_ajax() ) {
2105 return;
2106 }
2107
2108 global $pagenow;
2109 if ( 'profile.php' === $pagenow ) {
2110 return;
2111 }
2112
2113 $user_id = get_current_user_id();
2114 $must_change = get_user_meta( $user_id, 'vigilante_must_change_password', true );
2115
2116 if ( ! $must_change ) {
2117 return;
2118 }
2119
2120 // Re-validate against current settings: the admin may have removed
2121 // this user's role from affected_roles or added the user to the
2122 // excluded list after the flag was set. Without this check the flag
2123 // outlives the configuration change and locks the user in a redirect
2124 // loop into profile.php.
2125 if ( ! $this->is_password_expiration_applicable( $user_id ) ) {
2126 delete_user_meta( $user_id, 'vigilante_must_change_password' );
2127 return;
2128 }
2129
2130 wp_safe_redirect( admin_url( 'profile.php#password' ) );
2131 exit;
2132 }
2133
2134 /**
2135 * Whether password expiration rules currently apply to a given user
2136 *
2137 * Used to detect stale flags after the admin changes affected_roles or
2138 * the per-user exclusion list.
2139 *
2140 * @param int $user_id User ID.
2141 * @return bool
2142 */
2143 private function is_password_expiration_applicable( $user_id ) {
2144 $settings = $this->options['password_expiration'] ?? array();
2145
2146 if ( empty( $settings['enabled'] ) ) {
2147 return false;
2148 }
2149
2150 $affected_roles = $settings['affected_roles'] ?? array( 'administrator', 'editor' );
2151 $excluded_users = array_map( 'absint', $settings['excluded_users'] ?? array() );
2152 $user = get_userdata( $user_id );
2153
2154 if ( ! $user || ! array_intersect( $user->roles, $affected_roles ) ) {
2155 return false;
2156 }
2157
2158 if ( in_array( (int) $user_id, $excluded_users, true ) ) {
2159 return false;
2160 }
2161
2162 return true;
2163 }
2164
2165 /**
2166 * Update password change date when password is changed
2167 *
2168 * @param int $user_id User ID.
2169 * @param WP_User $old_user_data Old user data.
2170 */
2171 public function update_password_change_date( $user_id, $old_user_data ) {
2172 // Check if password was changed
2173 $user = get_userdata( $user_id );
2174 if ( $user->user_pass !== $old_user_data->user_pass ) {
2175 update_user_meta( $user_id, 'vigilante_password_changed', time() );
2176 delete_user_meta( $user_id, 'vigilante_must_change_password' );
2177 delete_user_meta( $user_id, 'vigilante_password_reminder_sent' );
2178
2179 // Store password hash in history
2180 $this->add_password_to_history( $user_id, $user->user_pass );
2181 }
2182 }
2183
2184 /**
2185 * Send password expiry reminder emails (daily cron)
2186 *
2187 * Sends a single reminder per user when they enter the warning period.
2188 * Uses vigilante_password_reminder_sent meta to avoid duplicates.
2189 */
2190 public function send_password_expiry_reminders() {
2191 $settings = $this->options['password_expiration'] ?? array();
2192 $affected_roles = $settings['affected_roles'] ?? array( 'administrator', 'editor' );
2193 $excluded_users = array_map( 'absint', $settings['excluded_users'] ?? array() );
2194 $warning_days = absint( $settings['warning_days'] ?? 14 );
2195
2196 if ( empty( $affected_roles ) ) {
2197 return;
2198 }
2199
2200 $args = array(
2201 'role__in' => $affected_roles,
2202 'fields' => 'ID',
2203 );
2204
2205 if ( ! empty( $excluded_users ) ) {
2206 // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- Small admin-curated exclusion list.
2207 $args['exclude'] = $excluded_users;
2208 }
2209
2210 $users = get_users( $args );
2211
2212 // Asking for IDs only means WordPress never primes the usermeta cache, so
2213 // every get_user_meta() below would hit the database once per user. On a
2214 // site with many users in these roles that is one query per user, every day.
2215 if ( ! empty( $users ) ) {
2216 cache_users( $users );
2217 }
2218
2219 foreach ( $users as $user_id ) {
2220 // Skip if reminder already sent for this cycle
2221 if ( get_user_meta( $user_id, 'vigilante_password_reminder_sent', true ) ) {
2222 continue;
2223 }
2224
2225 $days_left = $this->get_days_until_expiration( $user_id );
2226
2227 // Send when user enters the warning window
2228 if ( $days_left > 0 && $days_left <= $warning_days ) {
2229 $this->send_single_password_reminder( $user_id, $days_left );
2230 update_user_meta( $user_id, 'vigilante_password_reminder_sent', time() );
2231 }
2232 }
2233 }
2234
2235 /**
2236 * Send password expiry reminder to a single user
2237 *
2238 * @param int $user_id User ID.
2239 * @param int $days_left Days until password expires.
2240 */
2241 private function send_single_password_reminder( $user_id, $days_left ) {
2242 $user = get_userdata( $user_id );
2243 if ( ! $user ) {
2244 return;
2245 }
2246
2247 $site_name = get_bloginfo( 'name' );
2248
2249 $subject = sprintf(
2250 /* translators: 1: Site name, 2: Number of days */
2251 __( '[%1$s] Your password expires in %2$d days', 'vigilante' ),
2252 $site_name,
2253 $days_left
2254 );
2255
2256 $body = Vigilante_Email_Template::p(
2257 sprintf(
2258 /* translators: 1: User display name, 2: Number of days */
2259 __( 'Hi %1$s, your password on this site will expire in %2$d days.', 'vigilante' ),
2260 $user->display_name,
2261 $days_left
2262 )
2263 );
2264 $body .= Vigilante_Email_Template::p(
2265 __( 'Please update your password before it expires to avoid any interruptions.', 'vigilante' )
2266 );
2267 $body .= Vigilante_Email_Template::button(
2268 admin_url( 'profile.php#password' ),
2269 __( 'Change your password', 'vigilante' )
2270 );
2271
2272 Vigilante_Email_Template::send( $user->user_email, $subject, __( 'Password expiry reminder', 'vigilante' ), $body );
2273 }
2274
2275 /**
2276 * Check if an admin password was changed and send alert
2277 *
2278 * Hooked independently of password_expiration so monitoring
2279 * works even without expiration enabled.
2280 *
2281 * @param int $user_id User ID.
2282 * @param WP_User $old_user_data Previous user data.
2283 */
2284 public function check_admin_password_change( $user_id, $old_user_data ) {
2285 $user = get_userdata( $user_id );
2286 if ( ! $user || $user->user_pass === $old_user_data->user_pass ) {
2287 return;
2288 }
2289
2290 if ( ! user_can( $user, 'administrator' ) ) {
2291 return;
2292 }
2293
2294 $current_user_id = get_current_user_id();
2295 $changed_by_self = ( $current_user_id === $user_id );
2296
2297 $this->send_admin_monitoring_alert(
2298 'admin_password_change',
2299 $changed_by_self
2300 ? sprintf(
2301 /* translators: %s: Username */
2302 __( 'Administrator "%s" changed their password', 'vigilante' ),
2303 $user->user_login
2304 )
2305 : sprintf(
2306 /* translators: 1: Target username, 2: Actor username */
2307 __( 'Password changed for administrator "%1$s" by "%2$s"', 'vigilante' ),
2308 $user->user_login,
2309 $current_user_id ? get_userdata( $current_user_id )->user_login : __( 'System', 'vigilante' )
2310 ),
2311 array(
2312 'user_id' => $user_id,
2313 'username' => $user->user_login,
2314 'changed_by' => $current_user_id,
2315 'changed_by_self' => $changed_by_self,
2316 )
2317 );
2318 }
2319
2320 /**
2321 * Set initial password change date for new users
2322 *
2323 * @param int $user_id User ID.
2324 */
2325 public function set_initial_password_date( $user_id ) {
2326 update_user_meta( $user_id, 'vigilante_password_changed', time() );
2327 }
2328
2329 /**
2330 * Check if new password is in history
2331 *
2332 * @param WP_Error $errors Error object.
2333 * @param bool $update Whether this is an update.
2334 * @param WP_User $user User object.
2335 */
2336 public function check_password_history( $errors, $update, $user ) {
2337 if ( ! $update || ! isset( $user->ID ) ) {
2338 return;
2339 }
2340
2341 // Read the new password from $user->user_pass (set by WordPress during this
2342 // hook), not from $_POST: no input/nonce sniff and, crucially, no sanitizing
2343 // — wp_check_password() must test the exact string WordPress stores, or the
2344 // reuse check would compare a mangled value and silently miss matches.
2345 if ( ! isset( $user->user_pass ) || '' === $user->user_pass ) {
2346 return;
2347 }
2348
2349 // Profile save that doesn't change the password: user_pass is still the
2350 // stored hash, so there is no new value to compare.
2351 $user_data = get_userdata( $user->ID );
2352 if ( $user_data && $user->user_pass === $user_data->user_pass ) {
2353 return;
2354 }
2355
2356 $new_password = (string) wp_unslash( $user->user_pass );
2357 $settings = $this->options['password_expiration'] ?? array();
2358 $history_count = absint( $settings['password_history'] ?? 3 );
2359
2360 if ( $history_count === 0 ) {
2361 return;
2362 }
2363
2364 $history = get_user_meta( $user->ID, 'vigilante_password_history', true );
2365 if ( ! is_array( $history ) ) {
2366 return;
2367 }
2368
2369 // Check if new password matches any in history
2370 foreach ( array_slice( $history, 0, $history_count ) as $old_hash ) {
2371 if ( wp_check_password( $new_password, $old_hash ) ) {
2372 $errors->add(
2373 'password_reused',
2374 sprintf(
2375 /* translators: %d: Number of passwords */
2376 __( 'You cannot reuse your last %d passwords. Please choose a different password.', 'vigilante' ),
2377 $history_count
2378 )
2379 );
2380 return;
2381 }
2382 }
2383 }
2384
2385 /**
2386 * Add password to history
2387 *
2388 * @param int $user_id User ID.
2389 * @param string $password_hash Password hash.
2390 */
2391 private function add_password_to_history( $user_id, $password_hash ) {
2392 $settings = $this->options['password_expiration'] ?? array();
2393 $history_count = absint( $settings['password_history'] ?? 3 );
2394
2395 if ( $history_count === 0 ) {
2396 return;
2397 }
2398
2399 $history = get_user_meta( $user_id, 'vigilante_password_history', true );
2400 if ( ! is_array( $history ) ) {
2401 $history = array();
2402 }
2403
2404 // Add new password to beginning
2405 array_unshift( $history, $password_hash );
2406
2407 // Keep only the required number
2408 $history = array_slice( $history, 0, $history_count + 1 );
2409
2410 update_user_meta( $user_id, 'vigilante_password_history', $history );
2411 }
2412
2413 /**
2414 * Check if user's password is expired
2415 *
2416 * @param int $user_id User ID.
2417 * @return bool
2418 */
2419 public function is_password_expired( $user_id ) {
2420 $settings = $this->options['password_expiration'] ?? array();
2421
2422 if ( empty( $settings['enabled'] ) ) {
2423 return false;
2424 }
2425
2426 $affected_roles = $settings['affected_roles'] ?? array( 'administrator', 'editor' );
2427 $excluded_users = array_map( 'absint', $settings['excluded_users'] ?? array() );
2428 $user = get_userdata( $user_id );
2429
2430 if ( ! $user || ! array_intersect( $user->roles, $affected_roles ) ) {
2431 return false;
2432 }
2433
2434 if ( in_array( (int) $user_id, $excluded_users, true ) ) {
2435 return false;
2436 }
2437
2438 $expire_days = absint( $settings['expire_days'] ?? 90 );
2439 $last_change = get_user_meta( $user_id, 'vigilante_password_changed', true );
2440
2441 // If no record, set it now (first time)
2442 if ( ! $last_change ) {
2443 update_user_meta( $user_id, 'vigilante_password_changed', time() );
2444 return false;
2445 }
2446
2447 $days_since_change = ( time() - $last_change ) / DAY_IN_SECONDS;
2448
2449 return $days_since_change > $expire_days;
2450 }
2451
2452 /**
2453 * Get days until password expires
2454 *
2455 * @param int $user_id User ID.
2456 * @return int Days until expiration, -1 if not applicable.
2457 */
2458 public function get_days_until_expiration( $user_id ) {
2459 $settings = $this->options['password_expiration'] ?? array();
2460
2461 if ( empty( $settings['enabled'] ) ) {
2462 return -1;
2463 }
2464
2465 $affected_roles = $settings['affected_roles'] ?? array( 'administrator', 'editor' );
2466 $excluded_users = array_map( 'absint', $settings['excluded_users'] ?? array() );
2467 $user = get_userdata( $user_id );
2468
2469 if ( ! $user || ! array_intersect( $user->roles, $affected_roles ) ) {
2470 return -1;
2471 }
2472
2473 if ( in_array( (int) $user_id, $excluded_users, true ) ) {
2474 return -1;
2475 }
2476
2477 $expire_days = absint( $settings['expire_days'] ?? 90 );
2478 $last_change = get_user_meta( $user_id, 'vigilante_password_changed', true );
2479
2480 if ( ! $last_change ) {
2481 return $expire_days;
2482 }
2483
2484 $days_since_change = ( time() - $last_change ) / DAY_IN_SECONDS;
2485 $days_left = $expire_days - $days_since_change;
2486
2487 return max( 0, floor( $days_left ) );
2488 }
2489
2490 // =========================================================================
2491 // Email Verification - Require email verification before login
2492 // =========================================================================
2493
2494 /**
2495 * Send verification email to new user
2496 *
2497 * @param int $user_id User ID.
2498 */
2499 public function send_verification_email( $user_id ) {
2500 $user = get_userdata( $user_id );
2501 if ( ! $user ) {
2502 return;
2503 }
2504
2505 // Generate verification token
2506 $token = wp_generate_password( 32, false );
2507 $token_hash = wp_hash( $token );
2508
2509 $settings = $this->options['email_verification'] ?? array();
2510 $expiry_hours = absint( $settings['token_expiry_hours'] ?? 24 );
2511 $expires = time() + ( $expiry_hours * HOUR_IN_SECONDS );
2512
2513 // Store token
2514 update_user_meta( $user_id, 'vigilante_verification_token', $token_hash );
2515 update_user_meta( $user_id, 'vigilante_verification_expires', $expires );
2516 update_user_meta( $user_id, 'vigilante_email_verified', false );
2517
2518 // Build verification URL
2519 $verify_url = add_query_arg(
2520 array(
2521 'vigilante_verify' => '1',
2522 'user_id' => $user_id,
2523 'token' => $token,
2524 ),
2525 wp_login_url()
2526 );
2527
2528 $site_name = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );
2529
2530 $subject = sprintf(
2531 /* translators: %s: Site name */
2532 __( '[%s] Please verify your email address', 'vigilante' ),
2533 $site_name
2534 );
2535
2536 $body = Vigilante_Email_Template::p(
2537 sprintf(
2538 /* translators: 1: Username, 2: Site name */
2539 __( 'Hello %1$s, thank you for registering on %2$s.', 'vigilante' ),
2540 $user->display_name,
2541 $site_name
2542 )
2543 );
2544 $body .= Vigilante_Email_Template::p( __( 'Please verify your email address by clicking the button below.', 'vigilante' ) );
2545 $body .= Vigilante_Email_Template::button( $verify_url, __( 'Verify email address', 'vigilante' ) );
2546 $body .= Vigilante_Email_Template::small(
2547 sprintf(
2548 /* translators: %d: Expiry hours */
2549 __( 'This link will expire in %d hours. If you did not create this account, please ignore this email.', 'vigilante' ),
2550 $expiry_hours
2551 )
2552 );
2553
2554 /**
2555 * Filters the verification email body
2556 *
2557 * @param string $body Email HTML body.
2558 * @param WP_User $user User object.
2559 * @param string $verify_url Verification URL.
2560 */
2561 $body = apply_filters( 'vigilante_verification_email_message', $body, $user, $verify_url );
2562
2563 Vigilante_Email_Template::send( $user->user_email, $subject, __( 'Email verification', 'vigilante' ), $body );
2564
2565 // Log
2566 if ( $this->activity_log ) {
2567 $this->activity_log->log(
2568 'user',
2569 'verification_email_sent',
2570 sprintf(
2571 /* translators: %s: Username */
2572 __( 'Verification email sent to user "%s"', 'vigilante' ),
2573 $user->user_login
2574 ),
2575 array( 'user_id' => $user_id, 'email' => $user->user_email ),
2576 'info'
2577 );
2578 }
2579 }
2580
2581 /**
2582 * Block unverified users from logging in
2583 *
2584 * @param WP_User $user User object.
2585 * @param string $password Password.
2586 * @return WP_User|WP_Error
2587 */
2588 public function block_unverified_user_login( $user, $password ) {
2589 if ( is_wp_error( $user ) ) {
2590 return $user;
2591 }
2592
2593 // Check if email is verified
2594 $verified = get_user_meta( $user->ID, 'vigilante_email_verified', true );
2595
2596 // If no meta exists, user was created before this feature - allow
2597 if ( '' === $verified ) {
2598 return $user;
2599 }
2600
2601 if ( ! $verified ) {
2602 $settings = $this->options['email_verification'] ?? array();
2603 $allow_resend = ! empty( $settings['allow_resend'] );
2604
2605 $message = __( '<strong>Email not verified:</strong> Please verify your email address before logging in.', 'vigilante' );
2606
2607 if ( $allow_resend ) {
2608 $resend_url = wp_nonce_url(
2609 add_query_arg(
2610 array(
2611 'vigilante_resend' => '1',
2612 'user_id' => $user->ID,
2613 ),
2614 wp_login_url()
2615 ),
2616 'vigilante_resend_verification_' . $user->ID,
2617 '_vigilante_nonce'
2618 );
2619 $message .= ' <a href="' . esc_url( $resend_url ) . '">' . __( 'Resend verification email', 'vigilante' ) . '</a>';
2620 }
2621
2622 return new WP_Error( 'email_not_verified', $message );
2623 }
2624
2625 return $user;
2626 }
2627
2628 /**
2629 * Handle email verification link
2630 */
2631 public function handle_email_verification() {
2632 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- only checking parameter presence for branching, no data modification.
2633 if ( empty( $_GET['vigilante_verify'] ) ) {
2634 // Check for resend request - user_id is read before wp_verify_nonce()
2635 // because the nonce action is user-specific. Nonce verified immediately after.
2636 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce verified below after extracting user_id for the action string.
2637 if ( ! empty( $_GET['vigilante_resend'] ) && ! empty( $_GET['user_id'] ) ) {
2638 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce verified on the next line using this value.
2639 $user_id = absint( $_GET['user_id'] );
2640
2641 // Verify nonce to prevent CSRF and user-ID probing.
2642 if ( ! isset( $_GET['_vigilante_nonce'] ) ||
2643 ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_vigilante_nonce'] ) ), 'vigilante_resend_verification_' . $user_id ) ) {
2644 wp_safe_redirect( add_query_arg( 'vigilante_message', 'invalid', wp_login_url() ) );
2645 exit;
2646 }
2647
2648 // Rate limiting: allow 1 resend every 5 minutes per user to prevent email spam.
2649 $transient_key = 'vigilante_resend_' . $user_id;
2650 if ( false === get_transient( $transient_key ) ) {
2651 $this->send_verification_email( $user_id );
2652 set_transient( $transient_key, 1, 5 * MINUTE_IN_SECONDS );
2653 }
2654
2655 wp_safe_redirect( add_query_arg( 'vigilante_message', 'resent', wp_login_url() ) );
2656 exit;
2657 }
2658 return;
2659 }
2660
2661 // Email verification uses a cryptographic token instead of a nonce,
2662 // since nonces are session-bound and expire - unsuitable for email links.
2663 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token-based verification below.
2664 $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0;
2665 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token-based verification below.
2666 $token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : '';
2667
2668 if ( ! $user_id || ! $token ) {
2669 wp_safe_redirect( add_query_arg( 'vigilante_message', 'invalid', wp_login_url() ) );
2670 exit;
2671 }
2672
2673 $stored_hash = get_user_meta( $user_id, 'vigilante_verification_token', true );
2674 $expires = get_user_meta( $user_id, 'vigilante_verification_expires', true );
2675
2676 // Check expiration
2677 if ( time() > $expires ) {
2678 wp_safe_redirect( add_query_arg( 'vigilante_message', 'expired', wp_login_url() ) );
2679 exit;
2680 }
2681
2682 // Verify token
2683 if ( ! hash_equals( $stored_hash, wp_hash( $token ) ) ) {
2684 wp_safe_redirect( add_query_arg( 'vigilante_message', 'invalid', wp_login_url() ) );
2685 exit;
2686 }
2687
2688 // Mark as verified
2689 update_user_meta( $user_id, 'vigilante_email_verified', true );
2690 delete_user_meta( $user_id, 'vigilante_verification_token' );
2691 delete_user_meta( $user_id, 'vigilante_verification_expires' );
2692
2693 $user = get_userdata( $user_id );
2694
2695 // Log
2696 if ( $this->activity_log ) {
2697 $this->activity_log->log(
2698 'user',
2699 'email_verified',
2700 sprintf(
2701 /* translators: %s: Username */
2702 __( 'Email verified for user "%s"', 'vigilante' ),
2703 $user ? $user->user_login : $user_id
2704 ),
2705 array( 'user_id' => $user_id ),
2706 'info'
2707 );
2708 }
2709
2710 // Check if user still needs approval
2711 $is_pending = get_user_meta( $user_id, 'vigilante_pending_approval', true );
2712
2713 if ( $is_pending ) {
2714 // User verified but still pending approval
2715 wp_safe_redirect(
2716 add_query_arg(
2717 array(
2718 'vigilante_registration' => 'verified_pending',
2719 '_vigilante_nonce' => wp_create_nonce( 'vigilante_registration_redirect' ),
2720 ),
2721 wp_login_url()
2722 )
2723 );
2724 exit;
2725 }
2726
2727 // No approval needed - send password setup email
2728 if ( $user ) {
2729 $this->send_password_setup_email( $user );
2730 }
2731
2732 wp_safe_redirect( add_query_arg( 'vigilante_message', 'verified', wp_login_url() ) );
2733 exit;
2734 }
2735
2736 /**
2737 * Show verification message on login page
2738 *
2739 * @param string $message Login message.
2740 * @return string
2741 */
2742 public function show_verification_message( $message ) {
2743 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2744 if ( empty( $_GET['vigilante_message'] ) ) {
2745 return $message;
2746 }
2747
2748 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2749 $status = sanitize_key( $_GET['vigilante_message'] );
2750
2751 switch ( $status ) {
2752 case 'verified':
2753 $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>';
2754 break;
2755 case 'invalid':
2756 $message = '<p class="message" style="border-left-color: #d63638;">' . esc_html__( 'Invalid verification link.', 'vigilante' ) . '</p>';
2757 break;
2758 case 'expired':
2759 $message = '<p class="message" style="border-left-color: #d63638;">' . esc_html__( 'Verification link has expired. Please request a new one.', 'vigilante' ) . '</p>';
2760 break;
2761 case 'resent':
2762 $message = '<p class="message">' . esc_html__( 'Verification email has been resent. Please check your inbox.', 'vigilante' ) . '</p>';
2763 break;
2764 }
2765
2766 return $message;
2767 }
2768
2769 /**
2770 * Check if user email is verified
2771 *
2772 * @param int $user_id User ID.
2773 * @return bool
2774 */
2775 public function is_email_verified( $user_id ) {
2776 $verified = get_user_meta( $user_id, 'vigilante_email_verified', true );
2777
2778 // If no meta exists, consider verified (old users)
2779 if ( '' === $verified ) {
2780 return true;
2781 }
2782
2783 return (bool) $verified;
2784 }
2785
2786 /* =========================================================================
2787 REGISTRATION FLOW CONTROL
2788 ========================================================================= */
2789
2790 /**
2791 * Suppress WordPress new user notification email when our modules are active.
2792 * We control when the password setup email is sent.
2793 *
2794 * @param array $email Email parameters.
2795 * @param WP_User $user User object.
2796 * @param string $blogname Site name.
2797 * @return array|false Empty array to suppress, or original to send.
2798 */
2799 public function suppress_new_user_email( $email, $user, $blogname ) {
2800 $registration_approval = $this->options['registration_approval'] ?? array();
2801 $email_verification = $this->options['email_verification'] ?? array();
2802
2803 // Check if this user's role requires approval
2804 $needs_approval = false;
2805 if ( ! empty( $registration_approval['enabled'] ) ) {
2806 $affected_roles = $registration_approval['affected_roles'] ?? array( 'subscriber' );
2807 $needs_approval = ! empty( array_intersect( $user->roles, $affected_roles ) );
2808 }
2809
2810 // Check if email verification is enabled
2811 $needs_verification = ! empty( $email_verification['enabled'] );
2812
2813 // Suppress WP email if either module applies to this user
2814 if ( $needs_approval || $needs_verification ) {
2815 // Return false to completely suppress the email
2816 return false;
2817 }
2818
2819 return $email;
2820 }
2821
2822 /**
2823 * Redirect after registration to show appropriate message.
2824 *
2825 * @param string $redirect_to Redirect URL.
2826 * @return string Modified redirect URL.
2827 */
2828 public function custom_registration_redirect( $redirect_to ) {
2829 $registration_approval = $this->options['registration_approval'] ?? array();
2830 $email_verification = $this->options['email_verification'] ?? array();
2831
2832 $approval_enabled = ! empty( $registration_approval['enabled'] );
2833 $verification_enabled = ! empty( $email_verification['enabled'] );
2834
2835 // Determine which message to show
2836 if ( $verification_enabled && $approval_enabled ) {
2837 $message = 'registered_verify_then_approval';
2838 } elseif ( $verification_enabled ) {
2839 $message = 'registered_verify';
2840 } elseif ( $approval_enabled ) {
2841 $message = 'registered_pending';
2842 } else {
2843 return $redirect_to;
2844 }
2845
2846 return add_query_arg(
2847 array(
2848 'vigilante_registration' => $message,
2849 '_vigilante_nonce' => wp_create_nonce( 'vigilante_registration_redirect' ),
2850 ),
2851 wp_login_url()
2852 );
2853 }
2854
2855 /**
2856 * Show registration pending message on login page.
2857 *
2858 * @param string $message Existing message.
2859 * @return string Modified message.
2860 */
2861 public function show_registration_pending_message( $message ) {
2862 // Verify nonce from the registration redirect before processing GET data.
2863 if ( ! isset( $_GET['_vigilante_nonce'] ) ||
2864 ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_vigilante_nonce'] ) ), 'vigilante_registration_redirect' ) ) {
2865 return $message;
2866 }
2867
2868 if ( empty( $_GET['vigilante_registration'] ) ) {
2869 return $message;
2870 }
2871
2872 $status = sanitize_key( $_GET['vigilante_registration'] );
2873
2874 switch ( $status ) {
2875 case 'registered_verify':
2876 $message = '<p class="message">' .
2877 esc_html__( 'Registration complete! Please check your email to verify your address before you can log in.', 'vigilante' ) .
2878 '</p>';
2879 break;
2880
2881 case 'registered_pending':
2882 $message = '<p class="message">' .
2883 esc_html__( 'Registration complete! Your account is pending approval by an administrator. You will receive an email once approved.', 'vigilante' ) .
2884 '</p>';
2885 break;
2886
2887 case 'registered_verify_then_approval':
2888 $message = '<p class="message">' .
2889 esc_html__( 'Registration complete! Please check your email to verify your address. Once verified, your account will be reviewed by an administrator.', 'vigilante' ) .
2890 '</p>';
2891 break;
2892
2893 case 'verified_pending':
2894 $message = '<p class="message">' .
2895 esc_html__( 'Email verified! Your account is now pending approval by an administrator. You will receive an email once approved.', 'vigilante' ) .
2896 '</p>';
2897 break;
2898 }
2899
2900 return $message;
2901 }
2902
2903 /**
2904 * Check if user needs approval (based on role settings).
2905 *
2906 * @param int $user_id User ID.
2907 * @return bool
2908 */
2909 public function user_needs_approval( $user_id ) {
2910 $user = get_userdata( $user_id );
2911 if ( ! $user ) {
2912 return false;
2913 }
2914
2915 $registration_approval = $this->options['registration_approval'] ?? array();
2916 if ( empty( $registration_approval['enabled'] ) ) {
2917 return false;
2918 }
2919
2920 $affected_roles = $registration_approval['affected_roles'] ?? array( 'subscriber' );
2921 return ! empty( array_intersect( $user->roles, $affected_roles ) );
2922 }
2923
2924 /**
2925 * Send password setup email to user.
2926 * This is sent when the user is ready to set their password (after verification/approval).
2927 *
2928 * @param WP_User $user User object.
2929 */
2930 public function send_password_setup_email( $user ) {
2931 $site_name = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );
2932
2933 // Generate password reset key
2934 $key = get_password_reset_key( $user );
2935 if ( is_wp_error( $key ) ) {
2936 return;
2937 }
2938
2939 $reset_url = network_site_url( "wp-login.php?action=rp&key=$key&login=" . rawurlencode( $user->user_login ), 'login' );
2940
2941 $subject = sprintf(
2942 /* translators: %s: Site name */
2943 __( '[%s] Set up your password', 'vigilante' ),
2944 $site_name
2945 );
2946
2947 $body = Vigilante_Email_Template::p(
2948 sprintf(
2949 /* translators: 1: Username, 2: Site name */
2950 __( 'Hello %1$s, your account on %2$s is now active.', 'vigilante' ),
2951 $user->display_name,
2952 $site_name
2953 )
2954 );
2955 $body .= Vigilante_Email_Template::p( __( 'Please set your password by clicking the button below.', 'vigilante' ) );
2956 $body .= Vigilante_Email_Template::button( $reset_url, __( 'Set your password', 'vigilante' ) );
2957 $body .= Vigilante_Email_Template::small( __( 'If you did not create this account, please ignore this email.', 'vigilante' ) );
2958
2959 /**
2960 * Filters the password setup email body
2961 *
2962 * @param string $body Email HTML body.
2963 * @param WP_User $user User object.
2964 * @param string $reset_url Password reset URL.
2965 */
2966 $body = apply_filters( 'vigilante_password_setup_email_message', $body, $user, $reset_url );
2967
2968 Vigilante_Email_Template::send( $user->user_email, $subject, __( 'Set up your password', 'vigilante' ), $body );
2969
2970 // Log
2971 if ( $this->activity_log ) {
2972 $this->activity_log->log(
2973 'user',
2974 'password_setup_email_sent',
2975 sprintf(
2976 /* translators: %s: Username */
2977 __( 'Password setup email sent to user "%s"', 'vigilante' ),
2978 $user->user_login
2979 ),
2980 array( 'user_id' => $user->ID, 'email' => $user->user_email ),
2981 'info'
2982 );
2983 }
2984 }
2985 }