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

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

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