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