PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.3
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.3
3.0.5 3.0.4 3.0.3 3.0.2 3.0.1 3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 All 93 releases
vigilante / includes / class-user-security.php

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

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