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

3,415 lines 132.6 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 /*
1254 * Only a login that would otherwise have succeeded is turned into this
1255 * rejection. Wrong credentials are left exactly as WordPress reported
1256 * them, and they are counted like any other failed login.
1257 *
1258 * Until 2.11.12 this ran for a WP_Error too, resolving the account from
1259 * the username, and replaced an incorrect_password with the rejection
1260 * below. A rejection of Vigilant's own is not counted towards the brute
1261 * force lockout, so any account with a pending forced reset could be
1262 * guessed at without limit: measured on 17 Sep 2026 against 2.11.11 and
1263 * against the first build of 2.11.12, six wrong passwords in a row, none
1264 * of them counted and no lockout at the end. The message this function
1265 * exists to show belongs to whoever typed the right password.
1266 */
1267 if ( ! ( $user instanceof WP_User ) ) {
1268 return $user;
1269 }
1270
1271 $login_user = $user;
1272
1273 // Check if this user has a pending forced reset.
1274 $force_reset = get_user_meta( $login_user->ID, 'vigilante_force_reset_pending', true );
1275 if ( ! $force_reset ) {
1276 return $user;
1277 }
1278
1279 // Not counted towards the brute force lockout: the rejection is
1280 // recognised by its error code (Vigilante_Login_Security::CONTROLLED_REJECTIONS).
1281
1282 // Surface the controlled rejection in the activity log so the admin
1283 // can tell apart "user fails login because they typed wrong password"
1284 // from "user fails login because we are forcing a reset".
1285 if ( $this->activity_log ) {
1286 $this->activity_log->log(
1287 'login',
1288 'force_reset_login_blocked',
1289 sprintf(
1290 /* translators: %s: Username */
1291 __( 'Login blocked for "%s" — pending forced password reset', 'vigilante' ),
1292 $login_user->user_login
1293 ),
1294 array(
1295 'user_id' => $login_user->ID,
1296 'username' => $login_user->user_login,
1297 ),
1298 'warning'
1299 );
1300 }
1301
1302 return new WP_Error(
1303 'vigilante_force_reset',
1304 __( '<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' )
1305 );
1306 }
1307
1308 /**
1309 * Clear force reset meta after user successfully resets their password
1310 *
1311 * Hooked to 'after_password_reset'. Also resets password expiration
1312 * tracking — reset_password() doesn't fire profile_update, so without
1313 * this the freshly-reset password may immediately be flagged as expired
1314 * again on next login, creating a redirect loop into profile.php.
1315 *
1316 * @param WP_User $user User object.
1317 */
1318 public function clear_force_reset_meta( $user ) {
1319 if ( ! $user || empty( $user->ID ) ) {
1320 return;
1321 }
1322
1323 delete_user_meta( $user->ID, 'vigilante_force_reset_pending' );
1324 update_user_meta( $user->ID, 'vigilante_password_changed', time() );
1325 delete_user_meta( $user->ID, 'vigilante_must_change_password' );
1326 delete_user_meta( $user->ID, 'vigilante_password_reminder_sent' );
1327 }
1328
1329 // =========================================================================
1330 // Registration Approval - Manual approval for new user registrations
1331 // =========================================================================
1332
1333 /**
1334 * Set new user as pending approval
1335 *
1336 * @param int $user_id User ID.
1337 */
1338 public function set_user_pending_approval( $user_id ) {
1339 $user = get_userdata( $user_id );
1340 if ( ! $user ) {
1341 return;
1342 }
1343
1344 $settings = $this->options['registration_approval'] ?? array();
1345 $affected_roles = $settings['affected_roles'] ?? array( 'subscriber' );
1346
1347 // Check if user role requires approval
1348 $user_roles = $user->roles;
1349 $needs_approval = array_intersect( $user_roles, $affected_roles );
1350
1351 if ( empty( $needs_approval ) ) {
1352 return;
1353 }
1354
1355 // Set pending status, on this site only (see site_user_meta_key()).
1356 update_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_approval' ), true );
1357 update_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_since' ), time() );
1358
1359 // Log
1360 if ( $this->activity_log ) {
1361 $this->activity_log->log(
1362 'user',
1363 'pending_approval',
1364 sprintf(
1365 /* translators: %s: Username */
1366 __( 'New user "%s" awaiting approval', 'vigilante' ),
1367 $user->user_login
1368 ),
1369 array( 'user_id' => $user_id, 'email' => $user->user_email ),
1370 'info'
1371 );
1372 }
1373
1374 // Notify admin
1375 if ( ! empty( $settings['notify_admin'] ) ) {
1376 $this->notify_admin_pending_user( $user );
1377 }
1378 }
1379
1380 /**
1381 * Block pending users from logging in
1382 *
1383 * @param WP_User $user User object.
1384 * @param string $password Password.
1385 * @return WP_User|WP_Error
1386 */
1387 public function block_pending_user_login( $user, $password ) {
1388 if ( is_wp_error( $user ) ) {
1389 return $user;
1390 }
1391
1392 if ( self::is_pending_anywhere( $user->ID ) ) {
1393 return new WP_Error(
1394 'pending_approval',
1395 __( '<strong>Account pending:</strong> Your account is awaiting administrator approval. You will receive an email once approved.', 'vigilante' )
1396 );
1397 }
1398
1399 return $user;
1400 }
1401
1402 /**
1403 * Show admin notice about pending users
1404 */
1405 public function show_pending_users_notice() {
1406 if ( ! current_user_can( 'manage_options' ) ) {
1407 return;
1408 }
1409
1410 $pending_users = $this->get_pending_users();
1411 $count = count( $pending_users );
1412
1413 if ( $count === 0 ) {
1414 return;
1415 }
1416
1417 $screen = get_current_screen();
1418 if ( $screen && 'toplevel_page_vigilante' === $screen->id ) {
1419 return; // Don't show on Vigilante page, shown in UI
1420 }
1421 ?>
1422 <div class="notice notice-warning">
1423 <p>
1424 <?php
1425 printf(
1426 /* translators: 1: Number of users, 2: Link to Vigilante */
1427 esc_html( _n(
1428 '%1$d user is awaiting approval. %2$s',
1429 '%1$d users are awaiting approval. %2$s',
1430 $count,
1431 'vigilante'
1432 ) ),
1433 absint( $count ),
1434 '<a href="' . esc_url( admin_url( 'admin.php?page=vigilante&tab=users#vigilante-section-users-pending' ) ) . '">' . esc_html__( 'Review in Vigilant', 'vigilante' ) . '</a>'
1435 );
1436 ?>
1437 </p>
1438 </div>
1439 <?php
1440 }
1441
1442 /**
1443 * A user meta key that belongs to one site, even on a network
1444 *
1445 * Registration approval is a per-site setting, but user meta is network
1446 * wide, so a single global key made the pending queue shared: an
1447 * administrator of one site saw, approved and rejected accounts waiting on
1448 * another, and clearing the flag cleared it for the whole network. Reported
1449 * by the wp.org automated review of 2.11.9.
1450 *
1451 * On a network the key carries the blog prefix, the way core does with
1452 * capabilities (wp_2_capabilities), so each site keeps its own queue. On a
1453 * single site the key is returned unchanged, so nothing has to be migrated
1454 * there and the stored data of every existing install keeps working.
1455 *
1456 * @since 2.11.10
1457 *
1458 * Note the default is null and not 0: wpdb::get_blog_prefix() reads null as
1459 * "the current blog", and 0 as the main site, so passing 0 here gave every
1460 * subsite the key of the main site and kept the queue shared. Caught by
1461 * matriz-red-repaso-21110.sh before this shipped.
1462 *
1463 * @param string $key Base meta key.
1464 * @param int|null $blog_id Blog to build it for. Current blog when null.
1465 * @return string
1466 */
1467 public static function site_user_meta_key( $key, $blog_id = null ) {
1468 global $wpdb;
1469
1470 if ( ! is_multisite() ) {
1471 return $key;
1472 }
1473
1474 return $wpdb->get_blog_prefix( $blog_id ) . $key;
1475 }
1476
1477 /**
1478 * Whether this account is waiting for approval on ANY site of the network
1479 *
1480 * The queue is per site and stays per site, because approving somebody is a
1481 * decision of the site they signed up to. Blocking them is a different
1482 * question with a different answer, and giving it the same one was a hole:
1483 * the session cookie WordPress issues is valid on every host of the network
1484 * (COOKIE_DOMAIN and COOKIEPATH, wp-includes/ms-default-constants.php:58-59
1485 * and :84-88), so an account held back on demo1 logged in through the main
1486 * site, where it carried no flag, and walked straight back into demo1 with
1487 * that cookie. Reproduced over HTTP by the second cross review of 2.11.10.
1488 * It is the same reasoning that two_factor_required_for() already applies:
1489 * network-wide cookie, network-wide enforcement.
1490 *
1491 * Read from the account's own meta in one pass rather than by asking site by
1492 * site, so the cost does not grow with the network. The legacy key with no
1493 * prefix is included because the migration that moves it runs on the first
1494 * admin page load and until then a waiting account has to keep being
1495 * blocked; reading both fails closed.
1496 *
1497 * @since 2.11.10
1498 *
1499 * @param int $user_id User ID.
1500 * @return bool
1501 */
1502 public static function is_pending_anywhere( $user_id ) {
1503 global $wpdb;
1504
1505 if ( get_user_meta( $user_id, 'vigilante_pending_approval', true ) ) {
1506 return true;
1507 }
1508
1509 if ( ! is_multisite() ) {
1510 return false;
1511 }
1512
1513 $all = get_user_meta( $user_id );
1514
1515 if ( ! is_array( $all ) ) {
1516 return false;
1517 }
1518
1519 $pattern = '/^' . preg_quote( $wpdb->base_prefix, '/' ) . '(\d+_)?vigilante_pending_approval$/';
1520
1521 foreach ( $all as $key => $values ) {
1522 if ( ! preg_match( $pattern, $key, $m ) ) {
1523 continue;
1524 }
1525
1526 /*
1527 * A mark left behind by a site that no longer exists asks nobody for
1528 * anything: deleting a subsite does not touch this plugin's user meta,
1529 * so the account stayed blocked on the whole network with no queue
1530 * anywhere to clear it from, in a plugin whose users have no WP-CLI.
1531 * Found by the third cross review of 2.11.10. get_site() is cached, so
1532 * this costs nothing in the usual case of no leftovers.
1533 */
1534 if ( ! empty( $m[1] ) && ! get_site( (int) rtrim( $m[1], '_' ) ) ) {
1535 continue;
1536 }
1537
1538 foreach ( (array) $values as $value ) {
1539 if ( ! empty( $value ) ) {
1540 return true;
1541 }
1542 }
1543 }
1544
1545 return false;
1546 }
1547
1548 /**
1549 * Get pending users
1550 *
1551 * The meta key is what scopes this list to the current site, so on a network
1552 * the query deliberately does not add the site's own membership filter on
1553 * top. Core's WP_User_Query turns the default into "{$prefix}capabilities
1554 * EXISTS" (wp-includes/class-wp-user-query.php:598-604), and an account that
1555 * is waiting for approval can perfectly well have no role yet: it then held
1556 * this site's flag, was blocked from logging in, and appeared in no queue at
1557 * all, so nobody could ever approve or reject it. Found by the second cross
1558 * review of 2.11.10.
1559 *
1560 * @return array Array of pending user objects.
1561 */
1562 public function get_pending_users() {
1563 // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Limited results in admin context.
1564 $args = array(
1565 'meta_key' => self::site_user_meta_key( 'vigilante_pending_approval' ),
1566 'meta_value' => '1',
1567 'orderby' => 'registered',
1568 'order' => 'DESC',
1569 );
1570 // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
1571
1572 if ( is_multisite() ) {
1573 $args['blog_id'] = 0;
1574 }
1575
1576 return get_users( $args );
1577 }
1578
1579 /**
1580 * Approve a pending user
1581 *
1582 * @param int $user_id User ID.
1583 * @param int $approved_by Admin user ID who approved.
1584 * @return bool
1585 */
1586 public function approve_user( $user_id, $approved_by = 0 ) {
1587 // Same reasoning as reject_user(): approving an account that never asked
1588 // for approval is a no-op that reports success and writes misleading meta.
1589 // Only this site's flag counts, so approving never clears the queue of
1590 // another site of the network (see site_user_meta_key()).
1591 if ( ! get_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_approval' ), true ) ) {
1592 return false;
1593 }
1594
1595 $user = get_userdata( $user_id );
1596 if ( ! $user ) {
1597 return false;
1598 }
1599
1600 delete_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_approval' ) );
1601 delete_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_since' ) );
1602 // Por sitio como las dos de arriba: quien aprueba y cuando es un hecho de
1603 // la cola de ESTE sitio, y dejarlas globales hacia que una aprobacion
1604 // pisara el registro de otro (cierra B4 de la revision cruzada).
1605 update_user_meta( $user_id, self::site_user_meta_key( 'vigilante_approved_by' ), $approved_by );
1606 update_user_meta( $user_id, self::site_user_meta_key( 'vigilante_approved_date' ), time() );
1607
1608 // Log
1609 if ( $this->activity_log ) {
1610 $admin = $approved_by ? get_userdata( $approved_by ) : null;
1611 $this->activity_log->log(
1612 'user',
1613 'user_approved',
1614 sprintf(
1615 /* translators: 1: Username, 2: Admin username */
1616 __( 'User "%1$s" approved by %2$s', 'vigilante' ),
1617 $user->user_login,
1618 $admin ? $admin->user_login : __( 'System', 'vigilante' )
1619 ),
1620 array( 'user_id' => $user_id, 'approved_by' => $approved_by ),
1621 'info'
1622 );
1623 }
1624
1625 // Send approval email
1626 $this->send_approval_email( $user );
1627
1628 return true;
1629 }
1630
1631 /**
1632 * Reject a pending user
1633 *
1634 * @param int $user_id User ID.
1635 * @param int $rejected_by Admin user ID who rejected.
1636 * @param string $reason Optional rejection reason.
1637 * @return bool
1638 */
1639 public function reject_user( $user_id, $rejected_by = 0, $reason = '' ) {
1640 $user = get_userdata( $user_id );
1641 if ( ! $user ) {
1642 return false;
1643 }
1644
1645 // Only an account actually waiting for approval may be rejected. Without
1646 // this the handler deletes any user id it is given, and wp_delete_user()
1647 // with no reassignment takes their posts with them, skipping the dialog
1648 // core always shows. Deleting a member is the Users screen's job.
1649 if ( ! get_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_approval' ), true ) ) {
1650 return false;
1651 }
1652
1653 // Log before deletion
1654 if ( $this->activity_log ) {
1655 $admin = $rejected_by ? get_userdata( $rejected_by ) : null;
1656 $this->activity_log->log(
1657 'user',
1658 'user_rejected',
1659 sprintf(
1660 /* translators: 1: Username, 2: Admin username */
1661 __( 'User "%1$s" rejected by %2$s', 'vigilante' ),
1662 $user->user_login,
1663 $admin ? $admin->user_login : __( 'System', 'vigilante' )
1664 ),
1665 array(
1666 'user_id' => $user_id,
1667 'rejected_by' => $rejected_by,
1668 'reason' => $reason,
1669 'email' => $user->user_email,
1670 ),
1671 'warning'
1672 );
1673 }
1674
1675 // Send rejection email before deleting
1676 $this->send_rejection_email( $user, $reason );
1677
1678 /*
1679 * The mark goes first, because on a network the account may well survive
1680 * the deletion: wp_delete_user() only calls remove_user_from_blog() there
1681 * (wp-admin/includes/user.php:440-442), which clears the role and nothing
1682 * of this plugin's own meta. Leaving it behind made Reject a loop with no
1683 * way out: the account stayed blocked on every site of the network, the
1684 * row never left the queue (which since 2.11.10 no longer hides accounts
1685 * without a role), and pressing Reject again sent the rejection email once
1686 * more and reported success. Found by the third cross review of 2.11.10.
1687 */
1688 delete_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_approval' ) );
1689 delete_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_since' ) );
1690
1691 // Delete user
1692 require_once ABSPATH . 'wp-admin/includes/user.php';
1693 return wp_delete_user( $user_id );
1694 }
1695
1696 /**
1697 * Notify admin about pending user
1698 *
1699 * @param WP_User $user User object.
1700 */
1701 private function notify_admin_pending_user( $user ) {
1702 $recipients = Vigilante_Email_Template::get_admin_recipients();
1703 $site_name = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );
1704
1705 $subject = sprintf(
1706 /* translators: %s: Site name */
1707 __( '[%s] New user registration pending approval', 'vigilante' ),
1708 $site_name
1709 );
1710
1711 $approve_url = admin_url( 'admin.php?page=vigilante&tab=users#vigilante-section-users-pending' );
1712
1713 $body = Vigilante_Email_Template::p( __( 'A new user has registered and is awaiting your approval.', 'vigilante' ) );
1714 $body .= Vigilante_Email_Template::data_table( array(
1715 __( 'Username', 'vigilante' ) => $user->user_login,
1716 __( 'Email', 'vigilante' ) => $user->user_email,
1717 ) );
1718 $body .= Vigilante_Email_Template::button( $approve_url, __( 'Review registration', 'vigilante' ) );
1719
1720 Vigilante_Email_Template::send( $recipients, $subject, __( 'New registration pending', 'vigilante' ), $body );
1721 }
1722
1723 /**
1724 * Send approval email to user
1725 *
1726 * @param WP_User $user User object.
1727 */
1728 private function send_approval_email( $user ) {
1729 $site_name = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );
1730
1731 // Generate password reset key so user can set their password
1732 $key = get_password_reset_key( $user );
1733 if ( is_wp_error( $key ) ) {
1734 // Fallback to simple login URL if key generation fails
1735 $action_url = wp_login_url();
1736 $action_text = __( 'You can now log in:', 'vigilante' );
1737 } else {
1738 $action_url = network_site_url( "wp-login.php?action=rp&key=$key&login=" . rawurlencode( $user->user_login ), 'login' );
1739 $action_text = __( 'Please set your password by clicking the link below:', 'vigilante' );
1740 }
1741
1742 $subject = sprintf(
1743 /* translators: %s: Site name */
1744 __( '[%s] Your account has been approved', 'vigilante' ),
1745 $site_name
1746 );
1747
1748 $body = Vigilante_Email_Template::success_box(
1749 sprintf(
1750 /* translators: 1: Username, 2: Site name */
1751 __( 'Hello %1$s, great news! Your account on %2$s has been approved.', 'vigilante' ),
1752 $user->display_name,
1753 $site_name
1754 )
1755 );
1756 $body .= Vigilante_Email_Template::p( $action_text );
1757 $body .= Vigilante_Email_Template::button( $action_url, __( 'Set up your account', 'vigilante' ) );
1758
1759 /**
1760 * Filters the approval email message
1761 *
1762 * @param string $body Email HTML body.
1763 * @param WP_User $user User object.
1764 */
1765 $body = apply_filters( 'vigilante_approval_email_message', $body, $user );
1766
1767 Vigilante_Email_Template::send( $user->user_email, $subject, __( 'Account approved', 'vigilante' ), $body );
1768 }
1769
1770 /**
1771 * Send rejection email to user
1772 *
1773 * @param WP_User $user User object.
1774 * @param string $reason Rejection reason.
1775 */
1776 private function send_rejection_email( $user, $reason = '' ) {
1777 $site_name = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );
1778
1779 $subject = sprintf(
1780 /* translators: %s: Site name */
1781 __( '[%s] Your registration was not approved', 'vigilante' ),
1782 $site_name
1783 );
1784
1785 $body = Vigilante_Email_Template::p(
1786 sprintf(
1787 /* translators: 1: Username, 2: Site name */
1788 __( 'Hello %1$s, your registration on %2$s was not approved.', 'vigilante' ),
1789 $user->display_name,
1790 $site_name
1791 )
1792 );
1793
1794 if ( ! empty( $reason ) ) {
1795 $body .= Vigilante_Email_Template::info_box(
1796 sprintf(
1797 /* translators: %s: Reason */
1798 __( 'Reason: %s', 'vigilante' ),
1799 $reason
1800 )
1801 );
1802 }
1803
1804 /**
1805 * Filters the rejection email message
1806 *
1807 * @param string $body Email HTML body.
1808 * @param WP_User $user User object.
1809 * @param string $reason Rejection reason.
1810 */
1811 $body = apply_filters( 'vigilante_rejection_email_message', $body, $user, $reason );
1812
1813 Vigilante_Email_Template::send( $user->user_email, $subject, __( 'Registration not approved', 'vigilante' ), $body );
1814 }
1815
1816 // =========================================================================
1817 // Session Management - View and revoke user sessions
1818 // =========================================================================
1819
1820 /**
1821 * Check if user has sessions with corrupted format (numeric keys instead of hash keys)
1822 *
1823 * @param int $user_id User ID.
1824 * @return bool True if corrupted sessions found.
1825 */
1826 public function has_corrupted_sessions( $user_id ) {
1827 $all_sessions = get_user_meta( $user_id, 'session_tokens', true );
1828
1829 if ( ! is_array( $all_sessions ) || empty( $all_sessions ) ) {
1830 return false;
1831 }
1832
1833 foreach ( $all_sessions as $key => $session ) {
1834 // If any key is numeric or not a valid hash, sessions are corrupted
1835 if ( is_int( $key ) || ! is_string( $key ) || strlen( $key ) < 32 ) {
1836 return true;
1837 }
1838 }
1839
1840 return false;
1841 }
1842
1843 /**
1844 * Get raw session count (including corrupted ones)
1845 *
1846 * @param int $user_id User ID.
1847 * @return int Number of sessions in database.
1848 */
1849 public function get_raw_session_count( $user_id ) {
1850 $all_sessions = get_user_meta( $user_id, 'session_tokens', true );
1851 return is_array( $all_sessions ) ? count( $all_sessions ) : 0;
1852 }
1853
1854 /**
1855 * Get user sessions with details
1856 *
1857 * @param int $user_id User ID.
1858 * @return array Array of sessions with details.
1859 */
1860 public function get_user_sessions( $user_id ) {
1861 // Get sessions directly from user meta to preserve keys
1862 $all_sessions = get_user_meta( $user_id, 'session_tokens', true );
1863
1864 if ( ! is_array( $all_sessions ) || empty( $all_sessions ) ) {
1865 return array();
1866 }
1867
1868 $formatted = array();
1869 foreach ( $all_sessions as $token_hash => $session ) {
1870 // Skip if token_hash is not a valid hash (should be 64 char hex string)
1871 if ( ! is_string( $token_hash ) || strlen( $token_hash ) < 32 ) {
1872 continue;
1873 }
1874
1875 $formatted[] = array(
1876 'token_hash' => $token_hash,
1877 'ip' => $session['ip'] ?? __( 'Unknown', 'vigilante' ),
1878 'ua' => $session['ua'] ?? __( 'Unknown', 'vigilante' ),
1879 'login' => $session['login'] ?? 0,
1880 'expiration' => $session['expiration'] ?? 0,
1881 'browser' => $this->parse_user_agent( $session['ua'] ?? '' ),
1882 'is_current' => $this->is_current_session( $token_hash ),
1883 );
1884 }
1885
1886 return $formatted;
1887 }
1888
1889 /**
1890 * Parse user agent string to get browser info
1891 *
1892 * @param string $ua User agent string.
1893 * @return string Browser name and version.
1894 */
1895 private function parse_user_agent( $ua ) {
1896 if ( empty( $ua ) ) {
1897 return __( 'Unknown browser', 'vigilante' );
1898 }
1899
1900 $browser = __( 'Unknown browser', 'vigilante' );
1901
1902 if ( strpos( $ua, 'Firefox' ) !== false ) {
1903 preg_match( '/Firefox\/([0-9.]+)/', $ua, $matches );
1904 $browser = 'Firefox ' . ( $matches[1] ?? '' );
1905 } elseif ( strpos( $ua, 'Edg/' ) !== false ) {
1906 preg_match( '/Edg\/([0-9.]+)/', $ua, $matches );
1907 $browser = 'Edge ' . ( $matches[1] ?? '' );
1908 } elseif ( strpos( $ua, 'Chrome' ) !== false ) {
1909 preg_match( '/Chrome\/([0-9.]+)/', $ua, $matches );
1910 $browser = 'Chrome ' . ( $matches[1] ?? '' );
1911 } elseif ( strpos( $ua, 'Safari' ) !== false ) {
1912 preg_match( '/Version\/([0-9.]+)/', $ua, $matches );
1913 $browser = 'Safari ' . ( $matches[1] ?? '' );
1914 } elseif ( strpos( $ua, 'MSIE' ) !== false || strpos( $ua, 'Trident' ) !== false ) {
1915 $browser = 'Internet Explorer';
1916 }
1917
1918 // Add OS info
1919 $os = '';
1920 if ( strpos( $ua, 'Windows' ) !== false ) {
1921 $os = 'Windows';
1922 } elseif ( strpos( $ua, 'Mac OS' ) !== false ) {
1923 $os = 'macOS';
1924 } elseif ( strpos( $ua, 'Linux' ) !== false ) {
1925 $os = 'Linux';
1926 } elseif ( strpos( $ua, 'iPhone' ) !== false || strpos( $ua, 'iPad' ) !== false ) {
1927 $os = 'iOS';
1928 } elseif ( strpos( $ua, 'Android' ) !== false ) {
1929 $os = 'Android';
1930 }
1931
1932 return $os ? "$browser ($os)" : $browser;
1933 }
1934
1935 /**
1936 * Check if token is current session
1937 *
1938 * @param string $token_hash Session token hash.
1939 * @return bool
1940 */
1941 private function is_current_session( $token_hash ) {
1942 // Ensure token_hash is a valid string
1943 if ( ! is_string( $token_hash ) || empty( $token_hash ) ) {
1944 return false;
1945 }
1946
1947 $cookie = wp_parse_auth_cookie( '', 'logged_in' );
1948 if ( ! $cookie || empty( $cookie['token'] ) ) {
1949 return false;
1950 }
1951
1952 $current_hash = hash( 'sha256', $cookie['token'] );
1953 return hash_equals( $current_hash, $token_hash );
1954 }
1955
1956 /**
1957 * Revoke a specific session
1958 *
1959 * @param int $user_id User ID.
1960 * @param string $token_hash Session token verifier.
1961 * @return bool
1962 */
1963 public function revoke_session( $user_id, $token_hash ) {
1964 // Check if this is the current user's current session - don't allow revoking it
1965 if ( get_current_user_id() === (int) $user_id ) {
1966 $current_token = wp_get_session_token();
1967 if ( $current_token ) {
1968 $current_verifier = hash( 'sha256', $current_token );
1969 if ( $current_verifier === $token_hash ) {
1970 // Can't revoke your own current session
1971 return false;
1972 }
1973 }
1974 }
1975
1976 // Get sessions directly from user meta - bypass any caching
1977 wp_cache_delete( $user_id, 'user_meta' );
1978 $sessions = get_user_meta( $user_id, 'session_tokens', true );
1979
1980 if ( ! is_array( $sessions ) || ! isset( $sessions[ $token_hash ] ) ) {
1981 return false;
1982 }
1983
1984 // Remove the session
1985 unset( $sessions[ $token_hash ] );
1986
1987 // Save back to user meta
1988 if ( empty( $sessions ) ) {
1989 delete_user_meta( $user_id, 'session_tokens' );
1990 } else {
1991 update_user_meta( $user_id, 'session_tokens', $sessions );
1992 }
1993
1994 // Clear all related caches
1995 wp_cache_delete( $user_id, 'user_meta' );
1996 clean_user_cache( $user_id );
1997
1998 // Log
1999 if ( $this->activity_log ) {
2000 $user = get_userdata( $user_id );
2001 $this->activity_log->log(
2002 'user',
2003 'session_revoked',
2004 sprintf(
2005 /* translators: %s: Username */
2006 __( 'Session revoked for user "%s"', 'vigilante' ),
2007 $user ? $user->user_login : $user_id
2008 ),
2009 array( 'user_id' => $user_id ),
2010 'info'
2011 );
2012 }
2013
2014 return true;
2015 }
2016
2017 /**
2018 * Revoke all sessions except current
2019 *
2020 * @param int $user_id User ID.
2021 * @param bool $include_current Whether to revoke current session too.
2022 * @return int Number of sessions revoked.
2023 */
2024 public function revoke_all_sessions( $user_id, $include_current = false ) {
2025 $manager = WP_Session_Tokens::get_instance( $user_id );
2026 $all_sessions = $manager->get_all();
2027 $count = count( $all_sessions );
2028
2029 if ( $count === 0 ) {
2030 return 0;
2031 }
2032
2033 if ( $include_current ) {
2034 // Delete all sessions using WP native method
2035 $manager->destroy_all();
2036 } else {
2037 // For current user, use destroy_others which preserves current session
2038 if ( get_current_user_id() === $user_id ) {
2039 $current_token = wp_get_session_token();
2040 if ( $current_token ) {
2041 $manager->destroy_others( $current_token );
2042 $count--; // Don't count current session
2043 } else {
2044 // No current token found, destroy all
2045 $manager->destroy_all();
2046 }
2047 } else {
2048 // Admin revoking another user's sessions - destroy all of them
2049 $manager->destroy_all();
2050 }
2051 }
2052
2053 // Log
2054 if ( $this->activity_log && $count > 0 ) {
2055 $user = get_userdata( $user_id );
2056 $this->activity_log->log(
2057 'user',
2058 'all_sessions_revoked',
2059 sprintf(
2060 /* translators: 1: Number of sessions, 2: Username */
2061 __( '%1$d sessions revoked for user "%2$s"', 'vigilante' ),
2062 $count,
2063 $user ? $user->user_login : $user_id
2064 ),
2065 array( 'user_id' => $user_id, 'count' => $count ),
2066 'info'
2067 );
2068 }
2069
2070 return max( 0, $count );
2071 }
2072
2073 /**
2074 * Whether the session store this limit would act on belongs to a whole network
2075 *
2076 * WP_Session_Tokens keeps session_tokens in the usermeta table, which is
2077 * network wide, while this limit is configured per site. So on a network a
2078 * site administrator setting a low limit would count, and with close_oldest
2079 * close, the sessions the same user opened on other sites, including an
2080 * administrator session elsewhere; and block_new would refuse a login over
2081 * sessions that have nothing to do with this site. Reported by the wp.org
2082 * automated review of 2.11.9, on the close_oldest half.
2083 *
2084 * Until the network-wide policy of 3.1.0, the limit simply does not apply on
2085 * a network, and the settings screen says so. On a single site nothing
2086 * changes: there the session store and the setting cover the same thing.
2087 *
2088 * @since 2.11.10
2089 *
2090 * @return bool
2091 */
2092 public static function session_limit_is_network_wide() {
2093 return is_multisite();
2094 }
2095
2096 /**
2097 * Check session limit before login completes (for block_new behavior)
2098 *
2099 * @param WP_User $user User object.
2100 * @param string $password Password.
2101 * @return WP_User|WP_Error
2102 */
2103 public function check_session_limit_before_login( $user, $password ) {
2104 if ( is_wp_error( $user ) ) {
2105 return $user;
2106 }
2107
2108 if ( self::session_limit_is_network_wide() ) {
2109 return $user;
2110 }
2111
2112 $settings = $this->options['session_limits'] ?? array();
2113 $max_sessions = absint( $settings['max_sessions'] ?? 3 );
2114 $exclude_admins = ! empty( $settings['exclude_admins'] );
2115
2116 // Skip admins if excluded
2117 if ( $exclude_admins && user_can( $user, 'administrator' ) ) {
2118 return $user;
2119 }
2120
2121 $sessions = WP_Session_Tokens::get_instance( $user->ID );
2122 $all_sessions = $sessions->get_all();
2123 $session_count = count( $all_sessions );
2124
2125 // Block if already at or over limit
2126 if ( $session_count >= $max_sessions ) {
2127 // Log
2128 if ( $this->activity_log ) {
2129 $this->activity_log->log(
2130 'user',
2131 'session_limit_blocked',
2132 sprintf(
2133 /* translators: 1: Username, 2: Max sessions */
2134 __( 'Login blocked for "%1$s" - too many active sessions (limit: %2$d)', 'vigilante' ),
2135 $user->user_login,
2136 $max_sessions
2137 ),
2138 array( 'user_id' => $user->ID, 'current_sessions' => $session_count, 'limit' => $max_sessions ),
2139 'warning'
2140 );
2141 }
2142
2143 return new WP_Error(
2144 'session_limit_exceeded',
2145 sprintf(
2146 /* translators: %d: Maximum sessions allowed */
2147 __( '<strong>Session limit:</strong> You have too many active sessions (%d). Please log out from another device first, or contact an administrator.', 'vigilante' ),
2148 $max_sessions
2149 )
2150 );
2151 }
2152
2153 return $user;
2154 }
2155
2156 /**
2157 * Enforce session limit on login
2158 *
2159 * @param string $user_login Username.
2160 * @param WP_User $user User object.
2161 */
2162 public function enforce_session_limit( $user_login, $user ) {
2163 if ( self::session_limit_is_network_wide() ) {
2164 return;
2165 }
2166
2167 $settings = $this->options['session_limits'] ?? array();
2168 $max_sessions = absint( $settings['max_sessions'] ?? 3 );
2169 $behavior = $settings['behavior'] ?? 'block_new';
2170 $exclude_admins = ! empty( $settings['exclude_admins'] );
2171
2172 // Skip admins if excluded
2173 if ( $exclude_admins && user_can( $user, 'administrator' ) ) {
2174 return;
2175 }
2176
2177 $sessions = WP_Session_Tokens::get_instance( $user->ID );
2178 $all_sessions = $sessions->get_all();
2179 $session_count = count( $all_sessions );
2180
2181 // Check if over limit (accounting for the session just created)
2182 if ( $session_count <= $max_sessions ) {
2183 return;
2184 }
2185
2186 if ( 'close_oldest' === $behavior ) {
2187 /*
2188 * Remove the oldest sessions by editing the session store directly.
2189 *
2190 * WP_Session_Tokens::get_all() returns array_values( get_sessions() ),
2191 * so its keys are 0, 1, 2, not tokens, and destroy() expects a raw
2192 * token, which is not stored anywhere and cannot be recovered for a
2193 * session other than the current one. Until 2.11.9 the loop passed
2194 * those numeric keys to destroy(), which hashed them, matched nothing
2195 * and closed no session while still counting and logging success, so
2196 * the cap did nothing under close_oldest. Reported by the wp.org
2197 * automated review of 2.11.8.
2198 *
2199 * The store keeps the sessions as the user meta 'session_tokens',
2200 * keyed by the verifier hash( 'sha256', token ), which is the value
2201 * is_current_session() already compares against. So the oldest are
2202 * removed from that map, keeping the current session whatever its age.
2203 * On a network the meta is global (one finding of the multisite audit,
2204 * to be reworked in 3.1.0); here the fix is only to make the removal
2205 * actually happen.
2206 */
2207 $stored = get_user_meta( $user->ID, 'session_tokens', true );
2208
2209 if ( ! is_array( $stored ) || empty( $stored ) ) {
2210 return;
2211 }
2212
2213 $now = time();
2214 $changed = false;
2215
2216 // Expired sessions are dead weight and count for nothing; drop them first.
2217 foreach ( $stored as $verifier => $session ) {
2218 if ( isset( $session['expiration'] ) && (int) $session['expiration'] < $now ) {
2219 unset( $stored[ $verifier ] );
2220 $changed = true;
2221 }
2222 }
2223
2224 // Oldest first, keeping the current session whatever its login time.
2225 uasort( $stored, function ( $a, $b ) {
2226 return ( $a['login'] ?? 0 ) <=> ( $b['login'] ?? 0 );
2227 } );
2228
2229 $sessions_to_remove = count( $stored ) - $max_sessions;
2230 $removed = 0;
2231
2232 foreach ( $stored as $verifier => $session ) {
2233 if ( $removed >= $sessions_to_remove ) {
2234 break;
2235 }
2236 if ( $this->is_current_session( $verifier ) ) {
2237 continue;
2238 }
2239 unset( $stored[ $verifier ] );
2240 $removed++;
2241 $changed = true;
2242 }
2243
2244 if ( $changed ) {
2245 update_user_meta( $user->ID, 'session_tokens', $stored );
2246 }
2247
2248 // Log
2249 if ( $this->activity_log && $removed > 0 ) {
2250 $this->activity_log->log(
2251 'user',
2252 'session_limit_enforced',
2253 sprintf(
2254 /* translators: 1: Number of sessions, 2: Username */
2255 __( '%1$d oldest sessions closed for user "%2$s" (session limit: %3$d)', 'vigilante' ),
2256 $removed,
2257 $user->user_login,
2258 $max_sessions
2259 ),
2260 array( 'user_id' => $user->ID, 'removed' => $removed, 'limit' => $max_sessions ),
2261 'info'
2262 );
2263 }
2264 }
2265 // Note: 'block_new' behavior is handled in check_session_limit_before_login
2266 }
2267
2268 // =========================================================================
2269 // Password Expiration - Force password change after X days
2270 // =========================================================================
2271
2272 /**
2273 * Check password expiration on login
2274 *
2275 * @param string $user_login Username.
2276 * @param WP_User $user User object.
2277 */
2278 public function check_password_expiration( $user_login, $user ) {
2279 if ( $this->is_password_expired( $user->ID ) ) {
2280 // Set flag to force password change
2281 update_user_meta( $user->ID, 'vigilante_must_change_password', true );
2282 }
2283 }
2284
2285 /**
2286 * Show password expiration warning notice
2287 */
2288 public function show_password_expiration_notice() {
2289 if ( ! is_user_logged_in() ) {
2290 return;
2291 }
2292
2293 $user_id = get_current_user_id();
2294 $settings = $this->options['password_expiration'] ?? array();
2295
2296 // Honor both affected_roles AND the per-user exclusion list, and
2297 // clear stale flags if the user no longer matches the rules.
2298 if ( ! $this->is_password_expiration_applicable( $user_id ) ) {
2299 // Only on a single site, for the same reason as in
2300 // force_password_change_redirect(): on a network the flag belongs to
2301 // the account, and this site's policy says nothing about the site
2302 // that set it. The 2.11.8 fix only covered that method, and this
2303 // notice cleared the flag anyway on the next admin page; found by
2304 // the cross review of 2.11.8.
2305 if ( ! is_multisite() && get_user_meta( $user_id, 'vigilante_must_change_password', true ) ) {
2306 delete_user_meta( $user_id, 'vigilante_must_change_password' );
2307 }
2308 return;
2309 }
2310
2311 // Check if must change password
2312 $must_change = get_user_meta( $user_id, 'vigilante_must_change_password', true );
2313 if ( $must_change ) {
2314 global $pagenow;
2315 $on_profile = ( 'profile.php' === $pagenow );
2316 ?>
2317 <div class="notice notice-error">
2318 <p>
2319 <strong><?php esc_html_e( 'Password change required', 'vigilante' ); ?></strong>
2320 <?php if ( $on_profile ) : ?>
2321 <?php esc_html_e( 'Your password has expired. Set a new password in the section below and save your profile to continue.', 'vigilante' ); ?>
2322 <?php else : ?>
2323 <?php
2324 printf(
2325 /* translators: %s: Link to profile */
2326 esc_html__( 'Your password has expired. Please %s now.', 'vigilante' ),
2327 '<a href="' . esc_url( admin_url( 'profile.php#password' ) ) . '">' . esc_html__( 'change your password', 'vigilante' ) . '</a>'
2328 );
2329 ?>
2330 <?php endif; ?>
2331 </p>
2332 <?php if ( $on_profile ) : ?>
2333 <p>
2334 <?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' ); ?>
2335 </p>
2336 <?php endif; ?>
2337 </div>
2338 <?php
2339 return;
2340 }
2341
2342 // Show warning if expiring soon
2343 $days_left = $this->get_days_until_expiration( $user_id );
2344 $warning_days = absint( $settings['warning_days'] ?? 14 );
2345
2346 if ( $days_left > 0 && $days_left <= $warning_days ) {
2347 ?>
2348 <div class="notice notice-warning is-dismissible">
2349 <p>
2350 <?php
2351 printf(
2352 /* translators: 1: Number of days, 2: Link to profile */
2353 esc_html( _n(
2354 'Your password will expire in %1$d day. Please %2$s.',
2355 'Your password will expire in %1$d days. Please %2$s.',
2356 $days_left,
2357 'vigilante'
2358 ) ),
2359 absint( $days_left ),
2360 '<a href="' . esc_url( admin_url( 'profile.php' ) ) . '">' . esc_html__( 'change it now', 'vigilante' ) . '</a>'
2361 );
2362 ?>
2363 </p>
2364 </div>
2365 <?php
2366 }
2367 }
2368
2369 /**
2370 * Whether this user must change an expired password before doing anything else
2371 *
2372 * The flag alone is not enough: it is re-checked against the current policy,
2373 * because the admin may have taken the user's role out of affected_roles or
2374 * added the user to the exclusion list after it was set, which would
2375 * otherwise lock them in a redirect loop. A stale flag is cleared on a
2376 * single site; on a network the meta is shared by every site and the policy
2377 * checked is only this site's, so it is left alone and simply not enforced
2378 * here (a network-wide rework is the 3.1.0 multisite item).
2379 *
2380 * @since 2.11.9
2381 *
2382 * @param int $user_id User ID.
2383 * @return bool
2384 */
2385 private function must_change_password( $user_id ) {
2386 if ( ! $user_id ) {
2387 return false;
2388 }
2389
2390 $flagged = (bool) get_user_meta( $user_id, 'vigilante_must_change_password', true );
2391
2392 if ( ! $this->is_password_expiration_applicable( $user_id ) ) {
2393 if ( $flagged && ! is_multisite() ) {
2394 delete_user_meta( $user_id, 'vigilante_must_change_password' );
2395 }
2396 return false;
2397 }
2398
2399 if ( $flagged ) {
2400 return true;
2401 }
2402
2403 // The flag is set at interactive login (check_password_expiration on
2404 // wp_login). A session that authenticates only through REST, XML-RPC or
2405 // an application password never fires wp_login, so the flag can be
2406 // absent while the password is in fact expired. Compute it on the fly
2407 // too, so a non-interactive route is not a way around the block. The
2408 // computation self-seeds the change date on first sight and never locks
2409 // out a user who has no record yet (see is_password_expired()).
2410 return $this->is_password_expired( $user_id );
2411 }
2412
2413 /**
2414 * Force a user with an expired password to change it, on wp-admin and AJAX
2415 *
2416 * Until 2.11.9 this only redirected wp-admin pages and skipped AJAX, so an
2417 * expired-password session kept working through admin-ajax, and the REST API
2418 * and the front end were not covered at all. The wp.org automated review of
2419 * 2.11.8 flagged it: setting a flag on login is not enforcement if the flag
2420 * is only read by one redirect. It is now enforced on every entry point,
2421 * here for wp-admin and AJAX and in the three methods below for REST, the
2422 * front end and XML-RPC. The only thing an affected user can still do is
2423 * change the password on profile.php or log out.
2424 */
2425 public function force_password_change_redirect() {
2426 if ( ! is_user_logged_in() || ! $this->must_change_password( get_current_user_id() ) ) {
2427 return;
2428 }
2429
2430 // AJAX: a redirect is useless, so the request is refused. Changing the
2431 // password is a profile.php form POST, not AJAX, so nothing the user
2432 // needs to fix this is blocked.
2433 if ( wp_doing_ajax() ) {
2434 wp_send_json_error(
2435 array( 'message' => __( 'Your password has expired. Change it in your profile before continuing.', 'vigilante' ) ),
2436 403
2437 );
2438 }
2439
2440 // profile.php is where the change happens; do not redirect it onto itself.
2441 global $pagenow;
2442 if ( 'profile.php' === $pagenow ) {
2443 return;
2444 }
2445
2446 wp_safe_redirect( admin_url( 'profile.php#password' ) );
2447 exit;
2448 }
2449
2450 /**
2451 * Refuse REST API requests from a user whose password has expired
2452 *
2453 * @since 2.11.9
2454 *
2455 * @param WP_Error|null|true $result Result of the earlier authentication checks.
2456 * @return WP_Error|null|true
2457 */
2458 public function block_expired_password_rest( $result ) {
2459 // Leave any decision another check already made, and do not act on
2460 // logged-out requests to public endpoints.
2461 if ( null !== $result && false !== $result ) {
2462 return $result;
2463 }
2464
2465 if ( is_user_logged_in() && $this->must_change_password( get_current_user_id() ) ) {
2466 return new WP_Error(
2467 'vigilante_password_expired',
2468 __( 'Your password has expired. Change it in your profile before using the REST API.', 'vigilante' ),
2469 array( 'status' => 403 )
2470 );
2471 }
2472
2473 return $result;
2474 }
2475
2476 /**
2477 * Send a user with an expired password to the change page from the front end
2478 *
2479 * @since 2.11.9
2480 */
2481 public function force_password_change_frontend() {
2482 if ( is_admin() || ! is_user_logged_in() || ! $this->must_change_password( get_current_user_id() ) ) {
2483 return;
2484 }
2485
2486 wp_safe_redirect( admin_url( 'profile.php#password' ) );
2487 exit;
2488 }
2489
2490 /**
2491 * Refuse XML-RPC calls from a user whose password has expired
2492 *
2493 * The last of the four non-wp-admin entry points. XML-RPC authenticates on
2494 * every call with the account credentials (a password or an application
2495 * password), so a session that never touches wp-admin could keep acting
2496 * through xmlrpc.php while the password sits expired. Scoped to XML-RPC
2497 * requests so an ordinary login, which the user needs to reach profile.php,
2498 * is never blocked here. Runs late on authenticate, after core and the
2499 * application-password handler have resolved the user.
2500 *
2501 * @since 2.11.9
2502 *
2503 * @param WP_User|WP_Error|null $user Result of the earlier authentication.
2504 * @return WP_User|WP_Error|null
2505 */
2506 public function block_expired_password_xmlrpc( $user ) {
2507 if ( ! ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) ) {
2508 return $user;
2509 }
2510
2511 if ( $user instanceof WP_User && $this->must_change_password( $user->ID ) ) {
2512 return new WP_Error(
2513 'vigilante_password_expired',
2514 __( 'Your password has expired. Change it in your profile before using XML-RPC.', 'vigilante' ),
2515 array( 'status' => 403 )
2516 );
2517 }
2518
2519 return $user;
2520 }
2521
2522 /**
2523 * Whether password expiration rules currently apply to a given user
2524 *
2525 * Used to detect stale flags after the admin changes affected_roles or
2526 * the per-user exclusion list.
2527 *
2528 * @param int $user_id User ID.
2529 * @return bool
2530 */
2531 private function is_password_expiration_applicable( $user_id ) {
2532 $settings = $this->options['password_expiration'] ?? array();
2533
2534 if ( empty( $settings['enabled'] ) ) {
2535 return false;
2536 }
2537
2538 $affected_roles = $settings['affected_roles'] ?? array( 'administrator', 'editor' );
2539 $excluded_users = array_map( 'absint', $settings['excluded_users'] ?? array() );
2540 $user = get_userdata( $user_id );
2541
2542 if ( ! $user || ! array_intersect( $user->roles, $affected_roles ) ) {
2543 return false;
2544 }
2545
2546 if ( in_array( (int) $user_id, $excluded_users, true ) ) {
2547 return false;
2548 }
2549
2550 return true;
2551 }
2552
2553 /**
2554 * Update password change date when password is changed
2555 *
2556 * @param int $user_id User ID.
2557 * @param WP_User $old_user_data Old user data.
2558 */
2559 public function update_password_change_date( $user_id, $old_user_data ) {
2560 // Check if password was changed
2561 $user = get_userdata( $user_id );
2562 if ( $user->user_pass !== $old_user_data->user_pass ) {
2563 update_user_meta( $user_id, 'vigilante_password_changed', time() );
2564 delete_user_meta( $user_id, 'vigilante_must_change_password' );
2565 delete_user_meta( $user_id, 'vigilante_password_reminder_sent' );
2566
2567 // Store password hash in history
2568 $this->add_password_to_history( $user_id, $user->user_pass );
2569 }
2570 }
2571
2572 /**
2573 * Send password expiry reminder emails (daily cron)
2574 *
2575 * Sends a single reminder per user when they enter the warning period.
2576 * Uses vigilante_password_reminder_sent meta to avoid duplicates.
2577 */
2578 public function send_password_expiry_reminders() {
2579 $settings = $this->options['password_expiration'] ?? array();
2580 $affected_roles = $settings['affected_roles'] ?? array( 'administrator', 'editor' );
2581 $excluded_users = array_map( 'absint', $settings['excluded_users'] ?? array() );
2582 $warning_days = absint( $settings['warning_days'] ?? 14 );
2583
2584 if ( empty( $affected_roles ) ) {
2585 return;
2586 }
2587
2588 $args = array(
2589 'role__in' => $affected_roles,
2590 'fields' => 'ID',
2591 );
2592
2593 if ( ! empty( $excluded_users ) ) {
2594 // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- Small admin-curated exclusion list.
2595 $args['exclude'] = $excluded_users;
2596 }
2597
2598 $users = get_users( $args );
2599
2600 // Asking for IDs only means WordPress never primes the usermeta cache, so
2601 // every get_user_meta() below would hit the database once per user. On a
2602 // site with many users in these roles that is one query per user, every day.
2603 if ( ! empty( $users ) ) {
2604 cache_users( $users );
2605 }
2606
2607 foreach ( $users as $user_id ) {
2608 // Skip if reminder already sent for this cycle
2609 if ( get_user_meta( $user_id, 'vigilante_password_reminder_sent', true ) ) {
2610 continue;
2611 }
2612
2613 $days_left = $this->get_days_until_expiration( $user_id );
2614
2615 // Send when user enters the warning window
2616 if ( $days_left > 0 && $days_left <= $warning_days ) {
2617 $this->send_single_password_reminder( $user_id, $days_left );
2618 update_user_meta( $user_id, 'vigilante_password_reminder_sent', time() );
2619 }
2620 }
2621 }
2622
2623 /**
2624 * Send password expiry reminder to a single user
2625 *
2626 * @param int $user_id User ID.
2627 * @param int $days_left Days until password expires.
2628 */
2629 private function send_single_password_reminder( $user_id, $days_left ) {
2630 $user = get_userdata( $user_id );
2631 if ( ! $user ) {
2632 return;
2633 }
2634
2635 $site_name = get_bloginfo( 'name' );
2636
2637 $subject = sprintf(
2638 /* translators: 1: Site name, 2: Number of days */
2639 __( '[%1$s] Your password expires in %2$d days', 'vigilante' ),
2640 $site_name,
2641 $days_left
2642 );
2643
2644 $body = Vigilante_Email_Template::p(
2645 sprintf(
2646 /* translators: 1: User display name, 2: Number of days */
2647 __( 'Hi %1$s, your password on this site will expire in %2$d days.', 'vigilante' ),
2648 $user->display_name,
2649 $days_left
2650 )
2651 );
2652 $body .= Vigilante_Email_Template::p(
2653 __( 'Please update your password before it expires to avoid any interruptions.', 'vigilante' )
2654 );
2655 $body .= Vigilante_Email_Template::button(
2656 admin_url( 'profile.php#password' ),
2657 __( 'Change your password', 'vigilante' )
2658 );
2659
2660 Vigilante_Email_Template::send( $user->user_email, $subject, __( 'Password expiry reminder', 'vigilante' ), $body );
2661 }
2662
2663 /**
2664 * Check if an admin password was changed and send alert
2665 *
2666 * Hooked independently of password_expiration so monitoring
2667 * works even without expiration enabled.
2668 *
2669 * @param int $user_id User ID.
2670 * @param WP_User $old_user_data Previous user data.
2671 */
2672 public function check_admin_password_change( $user_id, $old_user_data ) {
2673 $user = get_userdata( $user_id );
2674 if ( ! $user || $user->user_pass === $old_user_data->user_pass ) {
2675 return;
2676 }
2677
2678 if ( ! user_can( $user, 'administrator' ) ) {
2679 return;
2680 }
2681
2682 $current_user_id = get_current_user_id();
2683 $changed_by_self = ( $current_user_id === $user_id );
2684
2685 $this->send_admin_monitoring_alert(
2686 'admin_password_change',
2687 $changed_by_self
2688 ? sprintf(
2689 /* translators: %s: Username */
2690 __( 'Administrator "%s" changed their password', 'vigilante' ),
2691 $user->user_login
2692 )
2693 : sprintf(
2694 /* translators: 1: Target username, 2: Actor username */
2695 __( 'Password changed for administrator "%1$s" by "%2$s"', 'vigilante' ),
2696 $user->user_login,
2697 $current_user_id ? get_userdata( $current_user_id )->user_login : __( 'System', 'vigilante' )
2698 ),
2699 array(
2700 'user_id' => $user_id,
2701 'username' => $user->user_login,
2702 'changed_by' => $current_user_id,
2703 'changed_by_self' => $changed_by_self,
2704 )
2705 );
2706 }
2707
2708 /**
2709 * Set initial password change date for new users
2710 *
2711 * @param int $user_id User ID.
2712 */
2713 public function set_initial_password_date( $user_id ) {
2714 update_user_meta( $user_id, 'vigilante_password_changed', time() );
2715 }
2716
2717 /**
2718 * Check if new password is in history
2719 *
2720 * @param WP_Error $errors Error object.
2721 * @param bool $update Whether this is an update.
2722 * @param WP_User $user User object.
2723 */
2724 public function check_password_history( $errors, $update, $user ) {
2725 if ( ! $update || ! isset( $user->ID ) ) {
2726 return;
2727 }
2728
2729 // Read the new password from $user->user_pass (set by WordPress during this
2730 // hook), not from $_POST: no input/nonce sniff and, crucially, no sanitizing
2731 // — wp_check_password() must test the exact string WordPress stores, or the
2732 // reuse check would compare a mangled value and silently miss matches.
2733 if ( ! isset( $user->user_pass ) || '' === $user->user_pass ) {
2734 return;
2735 }
2736
2737 // Profile save that doesn't change the password: user_pass is still the
2738 // stored hash, so there is no new value to compare.
2739 $user_data = get_userdata( $user->ID );
2740 if ( $user_data && $user->user_pass === $user_data->user_pass ) {
2741 return;
2742 }
2743
2744 $new_password = (string) wp_unslash( $user->user_pass );
2745 $settings = $this->options['password_expiration'] ?? array();
2746 $history_count = absint( $settings['password_history'] ?? 3 );
2747
2748 if ( $history_count === 0 ) {
2749 return;
2750 }
2751
2752 $history = get_user_meta( $user->ID, 'vigilante_password_history', true );
2753 if ( ! is_array( $history ) ) {
2754 return;
2755 }
2756
2757 // Check if new password matches any in history
2758 foreach ( array_slice( $history, 0, $history_count ) as $old_hash ) {
2759 if ( wp_check_password( $new_password, $old_hash ) ) {
2760 $errors->add(
2761 'password_reused',
2762 sprintf(
2763 /* translators: %d: Number of passwords */
2764 __( 'You cannot reuse your last %d passwords. Please choose a different password.', 'vigilante' ),
2765 $history_count
2766 )
2767 );
2768 return;
2769 }
2770 }
2771 }
2772
2773 /**
2774 * Add password to history
2775 *
2776 * @param int $user_id User ID.
2777 * @param string $password_hash Password hash.
2778 */
2779 private function add_password_to_history( $user_id, $password_hash ) {
2780 $settings = $this->options['password_expiration'] ?? array();
2781 $history_count = absint( $settings['password_history'] ?? 3 );
2782
2783 if ( $history_count === 0 ) {
2784 return;
2785 }
2786
2787 $history = get_user_meta( $user_id, 'vigilante_password_history', true );
2788 if ( ! is_array( $history ) ) {
2789 $history = array();
2790 }
2791
2792 // Add new password to beginning
2793 array_unshift( $history, $password_hash );
2794
2795 // Keep only the required number
2796 $history = array_slice( $history, 0, $history_count + 1 );
2797
2798 update_user_meta( $user_id, 'vigilante_password_history', $history );
2799 }
2800
2801 /**
2802 * Check if user's password is expired
2803 *
2804 * @param int $user_id User ID.
2805 * @return bool
2806 */
2807 public function is_password_expired( $user_id ) {
2808 $settings = $this->options['password_expiration'] ?? array();
2809
2810 if ( empty( $settings['enabled'] ) ) {
2811 return false;
2812 }
2813
2814 $affected_roles = $settings['affected_roles'] ?? array( 'administrator', 'editor' );
2815 $excluded_users = array_map( 'absint', $settings['excluded_users'] ?? array() );
2816 $user = get_userdata( $user_id );
2817
2818 if ( ! $user || ! array_intersect( $user->roles, $affected_roles ) ) {
2819 return false;
2820 }
2821
2822 if ( in_array( (int) $user_id, $excluded_users, true ) ) {
2823 return false;
2824 }
2825
2826 $expire_days = absint( $settings['expire_days'] ?? 90 );
2827 $last_change = get_user_meta( $user_id, 'vigilante_password_changed', true );
2828
2829 // If no record, set it now (first time)
2830 if ( ! $last_change ) {
2831 update_user_meta( $user_id, 'vigilante_password_changed', time() );
2832 return false;
2833 }
2834
2835 $days_since_change = ( time() - $last_change ) / DAY_IN_SECONDS;
2836
2837 return $days_since_change > $expire_days;
2838 }
2839
2840 /**
2841 * Get days until password expires
2842 *
2843 * @param int $user_id User ID.
2844 * @return int Days until expiration, -1 if not applicable.
2845 */
2846 public function get_days_until_expiration( $user_id ) {
2847 $settings = $this->options['password_expiration'] ?? array();
2848
2849 if ( empty( $settings['enabled'] ) ) {
2850 return -1;
2851 }
2852
2853 $affected_roles = $settings['affected_roles'] ?? array( 'administrator', 'editor' );
2854 $excluded_users = array_map( 'absint', $settings['excluded_users'] ?? array() );
2855 $user = get_userdata( $user_id );
2856
2857 if ( ! $user || ! array_intersect( $user->roles, $affected_roles ) ) {
2858 return -1;
2859 }
2860
2861 if ( in_array( (int) $user_id, $excluded_users, true ) ) {
2862 return -1;
2863 }
2864
2865 $expire_days = absint( $settings['expire_days'] ?? 90 );
2866 $last_change = get_user_meta( $user_id, 'vigilante_password_changed', true );
2867
2868 if ( ! $last_change ) {
2869 return $expire_days;
2870 }
2871
2872 $days_since_change = ( time() - $last_change ) / DAY_IN_SECONDS;
2873 $days_left = $expire_days - $days_since_change;
2874
2875 return max( 0, floor( $days_left ) );
2876 }
2877
2878 // =========================================================================
2879 // Email Verification - Require email verification before login
2880 // =========================================================================
2881
2882 /**
2883 * Send verification email to new user
2884 *
2885 * @param int $user_id User ID.
2886 */
2887 public function send_verification_email( $user_id ) {
2888 /*
2889 * Never send an account that is already verified back to pending. The
2890 * resend link below reaches this, and while the pending value was
2891 * unreadable (see the note on the meta write) that was harmless; with
2892 * the check working, resending for a verified account would lock its
2893 * owner out of their own site.
2894 */
2895 if ( metadata_exists( 'user', $user_id, 'vigilante_email_verified' )
2896 && get_user_meta( $user_id, 'vigilante_email_verified', true )
2897 ) {
2898 return;
2899 }
2900
2901 $user = get_userdata( $user_id );
2902 if ( ! $user ) {
2903 return;
2904 }
2905
2906 // Generate verification token
2907 $token = wp_generate_password( 32, false );
2908 $token_hash = wp_hash( $token );
2909
2910 $settings = $this->options['email_verification'] ?? array();
2911 $expiry_hours = absint( $settings['token_expiry_hours'] ?? 24 );
2912 $expires = time() + ( $expiry_hours * HOUR_IN_SECONDS );
2913
2914 // Store token
2915 update_user_meta( $user_id, 'vigilante_verification_token', $token_hash );
2916 update_user_meta( $user_id, 'vigilante_verification_expires', $expires );
2917
2918 /*
2919 * '0' and not false. update_user_meta() stores false as an empty string
2920 * (maybe_serialize() returns it unchanged and wpdb writes it with %s), and
2921 * an empty string is what get_user_meta() also returns when there is no
2922 * row at all. So from the moment this feature existed until 2.11.10 the
2923 * value written to mean "not verified yet" was read back as "this account
2924 * predates the feature, let it in", and the branch that blocks the login
2925 * was unreachable. Found by the file-by-file review of 2.11.10. '0' is
2926 * falsy in PHP and survives the round trip, and the readers below tell an
2927 * absent row from a stored one with metadata_exists().
2928 */
2929 update_user_meta( $user_id, 'vigilante_email_verified', '0' );
2930
2931 // Build verification URL
2932 $verify_url = add_query_arg(
2933 array(
2934 'vigilante_verify' => '1',
2935 'user_id' => $user_id,
2936 'token' => $token,
2937 ),
2938 wp_login_url()
2939 );
2940
2941 $site_name = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );
2942
2943 $subject = sprintf(
2944 /* translators: %s: Site name */
2945 __( '[%s] Please verify your email address', 'vigilante' ),
2946 $site_name
2947 );
2948
2949 $body = Vigilante_Email_Template::p(
2950 sprintf(
2951 /* translators: 1: Username, 2: Site name */
2952 __( 'Hello %1$s, thank you for registering on %2$s.', 'vigilante' ),
2953 $user->display_name,
2954 $site_name
2955 )
2956 );
2957 $body .= Vigilante_Email_Template::p( __( 'Please verify your email address by clicking the button below.', 'vigilante' ) );
2958 $body .= Vigilante_Email_Template::button( $verify_url, __( 'Verify email address', 'vigilante' ) );
2959 $body .= Vigilante_Email_Template::small(
2960 sprintf(
2961 /* translators: %d: Expiry hours */
2962 __( 'This link will expire in %d hours. If you did not create this account, please ignore this email.', 'vigilante' ),
2963 $expiry_hours
2964 )
2965 );
2966
2967 /**
2968 * Filters the verification email body
2969 *
2970 * @param string $body Email HTML body.
2971 * @param WP_User $user User object.
2972 * @param string $verify_url Verification URL.
2973 */
2974 $body = apply_filters( 'vigilante_verification_email_message', $body, $user, $verify_url );
2975
2976 Vigilante_Email_Template::send( $user->user_email, $subject, __( 'Email verification', 'vigilante' ), $body );
2977
2978 // Log
2979 if ( $this->activity_log ) {
2980 $this->activity_log->log(
2981 'user',
2982 'verification_email_sent',
2983 sprintf(
2984 /* translators: %s: Username */
2985 __( 'Verification email sent to user "%s"', 'vigilante' ),
2986 $user->user_login
2987 ),
2988 array( 'user_id' => $user_id, 'email' => $user->user_email ),
2989 'info'
2990 );
2991 }
2992 }
2993
2994 /**
2995 * Block unverified users from logging in
2996 *
2997 * @param WP_User $user User object.
2998 * @param string $password Password.
2999 * @return WP_User|WP_Error
3000 */
3001 public function block_unverified_user_login( $user, $password ) {
3002 if ( is_wp_error( $user ) ) {
3003 return $user;
3004 }
3005
3006 /*
3007 * Only a row that does not exist means "created before this feature".
3008 * An existing row holding an empty string is an account that older
3009 * versions marked as pending, and it has to be blocked like any other:
3010 * reading both the same way is what made this check let everyone in
3011 * (see send_verification_email()).
3012 */
3013 if ( ! metadata_exists( 'user', $user->ID, 'vigilante_email_verified' ) ) {
3014 return $user;
3015 }
3016
3017 $verified = get_user_meta( $user->ID, 'vigilante_email_verified', true );
3018
3019 if ( ! $verified ) {
3020 $settings = $this->options['email_verification'] ?? array();
3021 $allow_resend = ! empty( $settings['allow_resend'] );
3022
3023 $message = __( '<strong>Email not verified:</strong> Please verify your email address before logging in.', 'vigilante' );
3024
3025 if ( $allow_resend ) {
3026 $resend_url = wp_nonce_url(
3027 add_query_arg(
3028 array(
3029 'vigilante_resend' => '1',
3030 'user_id' => $user->ID,
3031 ),
3032 wp_login_url()
3033 ),
3034 'vigilante_resend_verification_' . $user->ID,
3035 '_vigilante_nonce'
3036 );
3037 $message .= ' <a href="' . esc_url( $resend_url ) . '">' . __( 'Resend verification email', 'vigilante' ) . '</a>';
3038 }
3039
3040 return new WP_Error( 'email_not_verified', $message );
3041 }
3042
3043 return $user;
3044 }
3045
3046 /**
3047 * Handle email verification link
3048 */
3049 public function handle_email_verification() {
3050 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- only checking parameter presence for branching, no data modification.
3051 if ( empty( $_GET['vigilante_verify'] ) ) {
3052 // Check for resend request - user_id is read before wp_verify_nonce()
3053 // because the nonce action is user-specific. Nonce verified immediately after.
3054 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce verified below after extracting user_id for the action string.
3055 if ( ! empty( $_GET['vigilante_resend'] ) && ! empty( $_GET['user_id'] ) ) {
3056 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- nonce verified on the next line using this value.
3057 $user_id = absint( $_GET['user_id'] );
3058
3059 // Verify nonce to prevent CSRF and user-ID probing.
3060 if ( ! isset( $_GET['_vigilante_nonce'] ) ||
3061 ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_vigilante_nonce'] ) ), 'vigilante_resend_verification_' . $user_id ) ) {
3062 /*
3063 * Nothing is redirected to the login page until the request
3064 * has proved something, and a bad nonce proves nothing. It
3065 * used to answer with a redirect to wp_login_url(), which
3066 * under a custom login URL IS the secret address, so any
3067 * visitor could read it out of the Location header of a
3068 * request carrying garbage. Found by the third cross review
3069 * of 2.11.10. Returning leaves the request to render the page
3070 * it asked for, which tells nobody anything.
3071 */
3072 return;
3073 }
3074
3075 // Rate limiting: allow 1 resend every 5 minutes per user to prevent email spam.
3076 $transient_key = 'vigilante_resend_' . $user_id;
3077 if ( false === get_transient( $transient_key ) ) {
3078 $this->send_verification_email( $user_id );
3079 set_transient( $transient_key, 1, 5 * MINUTE_IN_SECONDS );
3080 }
3081
3082 wp_safe_redirect( add_query_arg( 'vigilante_message', 'resent', wp_login_url() ) );
3083 exit;
3084 }
3085 return;
3086 }
3087
3088 // Email verification uses a cryptographic token instead of a nonce,
3089 // since nonces are session-bound and expire - unsuitable for email links.
3090 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token-based verification below.
3091 $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0;
3092 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token-based verification below.
3093 $token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : '';
3094
3095 // Same as the resend above: no proof, no redirect, so the Location
3096 // header cannot be used to read the custom login URL.
3097 if ( ! $user_id || ! $token ) {
3098 return;
3099 }
3100
3101 $stored_hash = (string) get_user_meta( $user_id, 'vigilante_verification_token', true );
3102 $expires = (int) get_user_meta( $user_id, 'vigilante_verification_expires', true );
3103
3104 // The token first. Checking the expiry before it answered "expired" for
3105 // any account with no verification pending and "invalid" for one waiting,
3106 // so a wrong link revealed which user ids were waiting (2.11.8). Only the
3107 // holder of the right token learns that it expired.
3108 if ( '' === $stored_hash || ! hash_equals( $stored_hash, wp_hash( $token ) ) ) {
3109 return;
3110 }
3111
3112 if ( time() > $expires ) {
3113 wp_safe_redirect( add_query_arg( 'vigilante_message', 'expired', wp_login_url() ) );
3114 exit;
3115 }
3116
3117 // Mark as verified
3118 update_user_meta( $user_id, 'vigilante_email_verified', true );
3119 delete_user_meta( $user_id, 'vigilante_verification_token' );
3120 delete_user_meta( $user_id, 'vigilante_verification_expires' );
3121
3122 $user = get_userdata( $user_id );
3123
3124 // Log
3125 if ( $this->activity_log ) {
3126 $this->activity_log->log(
3127 'user',
3128 'email_verified',
3129 sprintf(
3130 /* translators: %s: Username */
3131 __( 'Email verified for user "%s"', 'vigilante' ),
3132 $user ? $user->user_login : $user_id
3133 ),
3134 array( 'user_id' => $user_id ),
3135 'info'
3136 );
3137 }
3138
3139 // Anywhere on the network, so the message matches what will actually
3140 // happen at the login: that is what blocks (see is_pending_anywhere()).
3141 if ( self::is_pending_anywhere( $user_id ) ) {
3142 // User verified but still pending approval
3143 wp_safe_redirect(
3144 add_query_arg(
3145 array(
3146 'vigilante_registration' => 'verified_pending',
3147 '_vigilante_nonce' => wp_create_nonce( 'vigilante_registration_redirect' ),
3148 ),
3149 wp_login_url()
3150 )
3151 );
3152 exit;
3153 }
3154
3155 // No approval needed - send password setup email
3156 if ( $user ) {
3157 $this->send_password_setup_email( $user );
3158 }
3159
3160 wp_safe_redirect( add_query_arg( 'vigilante_message', 'verified', wp_login_url() ) );
3161 exit;
3162 }
3163
3164 /**
3165 * Show verification message on login page
3166 *
3167 * @param string $message Login message.
3168 * @return string
3169 */
3170 public function show_verification_message( $message ) {
3171 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
3172 if ( empty( $_GET['vigilante_message'] ) ) {
3173 return $message;
3174 }
3175
3176 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
3177 $status = sanitize_key( $_GET['vigilante_message'] );
3178
3179 switch ( $status ) {
3180 case 'verified':
3181 $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>';
3182 break;
3183 case 'invalid':
3184 $message = '<p class="message" style="border-left-color: #d63638;">' . esc_html__( 'Invalid verification link.', 'vigilante' ) . '</p>';
3185 break;
3186 case 'expired':
3187 $message = '<p class="message" style="border-left-color: #d63638;">' . esc_html__( 'Verification link has expired. Please request a new one.', 'vigilante' ) . '</p>';
3188 break;
3189 case 'resent':
3190 $message = '<p class="message">' . esc_html__( 'Verification email has been resent. Please check your inbox.', 'vigilante' ) . '</p>';
3191 break;
3192 }
3193
3194 return $message;
3195 }
3196
3197 /**
3198 * Check if user email is verified
3199 *
3200 * @param int $user_id User ID.
3201 * @return bool
3202 */
3203 public function is_email_verified( $user_id ) {
3204 // Same reading as block_unverified_user_login(): only an absent row means
3205 // the account predates the feature. A stored empty string is an account
3206 // an older version left pending.
3207 if ( ! metadata_exists( 'user', $user_id, 'vigilante_email_verified' ) ) {
3208 return true;
3209 }
3210
3211 $verified = get_user_meta( $user_id, 'vigilante_email_verified', true );
3212
3213 return (bool) $verified;
3214 }
3215
3216 /* =========================================================================
3217 REGISTRATION FLOW CONTROL
3218 ========================================================================= */
3219
3220 /**
3221 * Suppress WordPress new user notification email when our modules are active.
3222 * We control when the password setup email is sent.
3223 *
3224 * @param array $email Email parameters.
3225 * @param WP_User $user User object.
3226 * @param string $blogname Site name.
3227 * @return array|false Empty array to suppress, or original to send.
3228 */
3229 public function suppress_new_user_email( $email, $user, $blogname ) {
3230 $registration_approval = $this->options['registration_approval'] ?? array();
3231 $email_verification = $this->options['email_verification'] ?? array();
3232
3233 // Check if this user's role requires approval
3234 $needs_approval = false;
3235 if ( ! empty( $registration_approval['enabled'] ) ) {
3236 $affected_roles = $registration_approval['affected_roles'] ?? array( 'subscriber' );
3237 $needs_approval = ! empty( array_intersect( $user->roles, $affected_roles ) );
3238 }
3239
3240 // Check if email verification is enabled
3241 $needs_verification = ! empty( $email_verification['enabled'] );
3242
3243 // Suppress WP email if either module applies to this user
3244 if ( $needs_approval || $needs_verification ) {
3245 // Return false to completely suppress the email
3246 return false;
3247 }
3248
3249 return $email;
3250 }
3251
3252 /**
3253 * Redirect after registration to show appropriate message.
3254 *
3255 * @param string $redirect_to Redirect URL.
3256 * @return string Modified redirect URL.
3257 */
3258 public function custom_registration_redirect( $redirect_to ) {
3259 $registration_approval = $this->options['registration_approval'] ?? array();
3260 $email_verification = $this->options['email_verification'] ?? array();
3261
3262 $approval_enabled = ! empty( $registration_approval['enabled'] );
3263 $verification_enabled = ! empty( $email_verification['enabled'] );
3264
3265 // Determine which message to show
3266 if ( $verification_enabled && $approval_enabled ) {
3267 $message = 'registered_verify_then_approval';
3268 } elseif ( $verification_enabled ) {
3269 $message = 'registered_verify';
3270 } elseif ( $approval_enabled ) {
3271 $message = 'registered_pending';
3272 } else {
3273 return $redirect_to;
3274 }
3275
3276 return add_query_arg(
3277 array(
3278 'vigilante_registration' => $message,
3279 '_vigilante_nonce' => wp_create_nonce( 'vigilante_registration_redirect' ),
3280 ),
3281 wp_login_url()
3282 );
3283 }
3284
3285 /**
3286 * Show registration pending message on login page.
3287 *
3288 * @param string $message Existing message.
3289 * @return string Modified message.
3290 */
3291 public function show_registration_pending_message( $message ) {
3292 // Verify nonce from the registration redirect before processing GET data.
3293 if ( ! isset( $_GET['_vigilante_nonce'] ) ||
3294 ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_vigilante_nonce'] ) ), 'vigilante_registration_redirect' ) ) {
3295 return $message;
3296 }
3297
3298 if ( empty( $_GET['vigilante_registration'] ) ) {
3299 return $message;
3300 }
3301
3302 $status = sanitize_key( $_GET['vigilante_registration'] );
3303
3304 switch ( $status ) {
3305 case 'registered_verify':
3306 $message = '<p class="message">' .
3307 esc_html__( 'Registration complete! Please check your email to verify your address before you can log in.', 'vigilante' ) .
3308 '</p>';
3309 break;
3310
3311 case 'registered_pending':
3312 $message = '<p class="message">' .
3313 esc_html__( 'Registration complete! Your account is pending approval by an administrator. You will receive an email once approved.', 'vigilante' ) .
3314 '</p>';
3315 break;
3316
3317 case 'registered_verify_then_approval':
3318 $message = '<p class="message">' .
3319 esc_html__( 'Registration complete! Please check your email to verify your address. Once verified, your account will be reviewed by an administrator.', 'vigilante' ) .
3320 '</p>';
3321 break;
3322
3323 case 'verified_pending':
3324 $message = '<p class="message">' .
3325 esc_html__( 'Email verified! Your account is now pending approval by an administrator. You will receive an email once approved.', 'vigilante' ) .
3326 '</p>';
3327 break;
3328 }
3329
3330 return $message;
3331 }
3332
3333 /**
3334 * Check if user needs approval (based on role settings).
3335 *
3336 * @param int $user_id User ID.
3337 * @return bool
3338 */
3339 public function user_needs_approval( $user_id ) {
3340 $user = get_userdata( $user_id );
3341 if ( ! $user ) {
3342 return false;
3343 }
3344
3345 $registration_approval = $this->options['registration_approval'] ?? array();
3346 if ( empty( $registration_approval['enabled'] ) ) {
3347 return false;
3348 }
3349
3350 $affected_roles = $registration_approval['affected_roles'] ?? array( 'subscriber' );
3351 return ! empty( array_intersect( $user->roles, $affected_roles ) );
3352 }
3353
3354 /**
3355 * Send password setup email to user.
3356 * This is sent when the user is ready to set their password (after verification/approval).
3357 *
3358 * @param WP_User $user User object.
3359 */
3360 public function send_password_setup_email( $user ) {
3361 $site_name = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );
3362
3363 // Generate password reset key
3364 $key = get_password_reset_key( $user );
3365 if ( is_wp_error( $key ) ) {
3366 return;
3367 }
3368
3369 $reset_url = network_site_url( "wp-login.php?action=rp&key=$key&login=" . rawurlencode( $user->user_login ), 'login' );
3370
3371 $subject = sprintf(
3372 /* translators: %s: Site name */
3373 __( '[%s] Set up your password', 'vigilante' ),
3374 $site_name
3375 );
3376
3377 $body = Vigilante_Email_Template::p(
3378 sprintf(
3379 /* translators: 1: Username, 2: Site name */
3380 __( 'Hello %1$s, your account on %2$s is now active.', 'vigilante' ),
3381 $user->display_name,
3382 $site_name
3383 )
3384 );
3385 $body .= Vigilante_Email_Template::p( __( 'Please set your password by clicking the button below.', 'vigilante' ) );
3386 $body .= Vigilante_Email_Template::button( $reset_url, __( 'Set your password', 'vigilante' ) );
3387 $body .= Vigilante_Email_Template::small( __( 'If you did not create this account, please ignore this email.', 'vigilante' ) );
3388
3389 /**
3390 * Filters the password setup email body
3391 *
3392 * @param string $body Email HTML body.
3393 * @param WP_User $user User object.
3394 * @param string $reset_url Password reset URL.
3395 */
3396 $body = apply_filters( 'vigilante_password_setup_email_message', $body, $user, $reset_url );
3397
3398 Vigilante_Email_Template::send( $user->user_email, $subject, __( 'Set up your password', 'vigilante' ), $body );
3399
3400 // Log
3401 if ( $this->activity_log ) {
3402 $this->activity_log->log(
3403 'user',
3404 'password_setup_email_sent',
3405 sprintf(
3406 /* translators: %s: Username */
3407 __( 'Password setup email sent to user "%s"', 'vigilante' ),
3408 $user->user_login
3409 ),
3410 array( 'user_id' => $user->ID, 'email' => $user->user_email ),
3411 'info'
3412 );
3413 }
3414 }
3415 }