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

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