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

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