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