| @@ -42,20 +42,57 @@ | ||
| 42 | 42 | |
| 43 | 43 | /** |
| 44 | 44 | * Constructor |
| 45 | 45 | * |
| 46 | - * @param Vigilante_Settings $settings Settings instance. | |
| 47 | - * @param Vigilante_Activity_Log $activity_log Activity log instance. | |
| 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(). | |
| 48 | 52 | */ |
| 49 | - public function __construct( $settings, $activity_log ) { | |
| 53 | + public function __construct( $settings, $activity_log, $enforcement_only = false ) { | |
| 50 | 54 | $this->settings = $settings; |
| 51 | 55 | $this->activity_log = $activity_log; |
| 52 | 56 | $this->options = $settings->get_section( 'user_security' ); |
| 53 | 57 | |
| 58 | + if ( $enforcement_only ) { | |
| 59 | + $this->init_enforcement_hooks(); | |
| 60 | + return; | |
| 61 | + } | |
| 62 | + | |
| 54 | 63 | $this->init_hooks(); |
| 55 | 64 | } |
| 56 | 65 | |
| 57 | 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 | + /** | |
| 58 | 95 | * Initialize hooks |
| 59 | 96 | */ |
| 60 | 97 | private function init_hooks() { |
| 61 | 98 | // Block insecure usernames |
| @@ -104,9 +141,10 @@ | ||
| 104 | 141 | // Registration approval |
| 105 | 142 | $registration_approval = $this->options['registration_approval'] ?? array(); |
| 106 | 143 | if ( ! empty( $registration_approval['enabled'] ) ) { |
| 107 | 144 | add_action( 'user_register', array( $this, 'set_user_pending_approval' ), 5 ); |
| 108 | - add_filter( 'wp_authenticate_user', array( $this, 'block_pending_user_login' ), 15, 2 ); | |
| 145 | + // The blocking half is registered by init_enforcement_hooks(), so an | |
| 146 | + // account already waiting keeps waiting if the feature is turned off. | |
| 109 | 147 | add_action( 'admin_notices', array( $this, 'show_pending_users_notice' ) ); |
| 110 | 148 | } |
| 111 | 149 | |
| 112 | 150 | // Session limits |
| @@ -131,8 +169,13 @@ | ||
| 131 | 169 | if ( ! empty( $password_expiration['enabled'] ) ) { |
| 132 | 170 | add_action( 'wp_login', array( $this, 'check_password_expiration' ), 10, 2 ); |
| 133 | 171 | add_action( 'admin_notices', array( $this, 'show_password_expiration_notice' ) ); |
| 134 | 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 ); | |
| 135 | 178 | add_action( 'profile_update', array( $this, 'update_password_change_date' ), 10, 2 ); |
| 136 | 179 | add_action( 'user_register', array( $this, 'set_initial_password_date' ) ); |
| 137 | 180 | add_action( 'user_profile_update_errors', array( $this, 'check_password_history' ), 10, 3 ); |
| 138 | 181 | |
| @@ -160,11 +203,11 @@ | ||
| 160 | 203 | add_filter( 'registration_redirect', array( $this, 'custom_registration_redirect' ) ); |
| 161 | 204 | add_action( 'login_message', array( $this, 'show_registration_pending_message' ) ); |
| 162 | 205 | } |
| 163 | 206 | |
| 164 | - // Force password reset login message (always active, independent of settings) | |
| 165 | - add_filter( 'authenticate', array( $this, 'check_force_reset_on_login' ), 30, 3 ); | |
| 166 | - add_action( 'after_password_reset', array( $this, 'clear_force_reset_meta' ), 10, 1 ); | |
| 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(); | |
| 167 | 210 | } |
| 168 | 211 | |
| 169 | 212 | /** |
| 170 | 213 | * Validate username on profile update |
| @@ -241,9 +284,11 @@ | ||
| 241 | 284 | * @return bool |
| 242 | 285 | */ |
| 243 | 286 | private function is_insecure_username( $username ) { |
| 244 | 287 | $username = strtolower( trim( $username ) ); |
| 245 | - $insecure_usernames = $this->options['insecure_usernames'] ?? array(); | |
| 288 | + // Vigilante_Settings::list_or_shipped(): an empty list of a setting with | |
| 289 | + // no field on screen is a leftover, not a choice. See its docblock. | |
| 290 | + $insecure_usernames = $this->get_insecure_usernames(); | |
| 246 | 291 | |
| 247 | 292 | return in_array( $username, array_map( 'strtolower', $insecure_usernames ), true ); |
| 248 | 293 | } |
| 249 | 294 | |
| @@ -945,9 +990,14 @@ | ||
| 945 | 990 | * |
| 946 | 991 | * @return array |
| 947 | 992 | */ |
| 948 | 993 | public function get_insecure_usernames() { |
| 949 | - return $this->options['insecure_usernames'] ?? array(); | |
| 994 | + // The single reader of this list, so what blocks and what the screen | |
| 995 | + // reports can never disagree. | |
| 996 | + return Vigilante_Settings::list_or_shipped( | |
| 997 | + $this->options['insecure_usernames'] ?? array(), | |
| 998 | + array( 'user_security', 'insecure_usernames' ) | |
| 999 | + ); | |
| 950 | 1000 | } |
| 951 | 1001 | |
| 952 | 1002 | /** |
| 953 | 1003 | * Check for existing insecure admin users |
| @@ -1094,13 +1144,23 @@ | ||
| 1094 | 1144 | public function force_password_reset_bulk( $user_ids, $reset_by_user_id = 0 ) { |
| 1095 | 1145 | $results = array( |
| 1096 | 1146 | 'success' => 0, |
| 1097 | 1147 | 'failed' => 0, |
| 1148 | + 'skipped' => 0, | |
| 1098 | 1149 | 'emails_sent' => 0, |
| 1099 | 1150 | 'total' => count( $user_ids ), |
| 1100 | 1151 | ); |
| 1101 | 1152 | |
| 1102 | 1153 | foreach ( $user_ids as $user_id ) { |
| 1154 | + // The caller only proved it holds manage_options, which on a network | |
| 1155 | + // every subsite administrator has. Resetting somebody else's password | |
| 1156 | + // locks them out, so each target is checked one by one. Skipped users | |
| 1157 | + // are counted apart from real failures. | |
| 1158 | + if ( ! current_user_can( 'edit_user', $user_id ) ) { | |
| 1159 | + $results['skipped']++; | |
| 1160 | + continue; | |
| 1161 | + } | |
| 1162 | + | |
| 1103 | 1163 | $result = $this->force_password_reset( $user_id, $reset_by_user_id ); |
| 1104 | 1164 | |
| 1105 | 1165 | if ( $result['success'] ) { |
| 1106 | 1166 | $results['success']++; |
| @@ -1196,22 +1256,27 @@ | ||
| 1196 | 1256 | * @param string $password Password. |
| 1197 | 1257 | * @return WP_User|WP_Error|null |
| 1198 | 1258 | */ |
| 1199 | 1259 | public function check_force_reset_on_login( $user, $username, $password ) { |
| 1200 | - // Resolve the target user. The flag must be evaluated whether the | |
| 1201 | - // credentials matched (WP_User) or not (WP_Error). | |
| 1202 | - if ( $user instanceof WP_User ) { | |
| 1203 | - $login_user = $user; | |
| 1204 | - } else { | |
| 1205 | - $login_user = get_user_by( 'login', $username ); | |
| 1206 | - if ( ! $login_user ) { | |
| 1207 | - $login_user = get_user_by( 'email', $username ); | |
| 1208 | - } | |
| 1260 | + /* | |
| 1261 | + * Only a login that would otherwise have succeeded is turned into this | |
| 1262 | + * rejection. Wrong credentials are left exactly as WordPress reported | |
| 1263 | + * them, and they are counted like any other failed login. | |
| 1264 | + * | |
| 1265 | + * Until 2.11.12 this ran for a WP_Error too, resolving the account from | |
| 1266 | + * the username, and replaced an incorrect_password with the rejection | |
| 1267 | + * below. A rejection of Vigilant's own is not counted towards the brute | |
| 1268 | + * force lockout, so any account with a pending forced reset could be | |
| 1269 | + * guessed at without limit: measured on 17 Sep 2026 against 2.11.11 and | |
| 1270 | + * against the first build of 2.11.12, six wrong passwords in a row, none | |
| 1271 | + * of them counted and no lockout at the end. The message this function | |
| 1272 | + * exists to show belongs to whoever typed the right password. | |
| 1273 | + */ | |
| 1274 | + if ( ! ( $user instanceof WP_User ) ) { | |
| 1275 | + return $user; | |
| 1209 | 1276 | } |
| 1210 | 1277 | |
| 1211 | - if ( ! $login_user ) { | |
| 1212 | - return $user; | |
| 1213 | - } | |
| 1278 | + $login_user = $user; | |
| 1214 | 1279 | |
| 1215 | 1280 | // Check if this user has a pending forced reset. |
| 1216 | 1281 | $force_reset = get_user_meta( $login_user->ID, 'vigilante_force_reset_pending', true ); |
| 1217 | 1282 | if ( ! $force_reset ) { |
| @@ -1217,17 +1282,11 @@ | ||
| 1217 | 1282 | if ( ! $force_reset ) { |
| 1218 | 1283 | return $user; |
| 1219 | 1284 | } |
| 1220 | 1285 | |
| 1221 | - // If credentials were wrong with an error other than incorrect_password | |
| 1222 | - // (e.g. a Vigilant lockout, pending approval), don't shadow it. | |
| 1223 | - if ( is_wp_error( $user ) && ! in_array( 'incorrect_password', $user->get_error_codes(), true ) ) { | |
| 1224 | - return $user; | |
| 1225 | - } | |
| 1286 | + // Not counted towards the brute force lockout: the rejection is | |
| 1287 | + // recognised by its error code (Vigilante_Login_Security::CONTROLLED_REJECTIONS). | |
| 1226 | 1288 | |
| 1227 | - // Skip brute force counter for this controlled rejection. | |
| 1228 | - add_filter( 'vigilante_skip_failed_login_count', '__return_true' ); | |
| 1229 | - | |
| 1230 | 1289 | // Surface the controlled rejection in the activity log so the admin |
| 1231 | 1290 | // can tell apart "user fails login because they typed wrong password" |
| 1232 | 1291 | // from "user fails login because we are forcing a reset". |
| 1233 | 1292 | if ( $this->activity_log ) { |
| @@ -1289,9 +1348,14 @@ | ||
| 1289 | 1348 | return; |
| 1290 | 1349 | } |
| 1291 | 1350 | |
| 1292 | 1351 | $settings = $this->options['registration_approval'] ?? array(); |
| 1293 | - $affected_roles = $settings['affected_roles'] ?? array( 'subscriber' ); | |
| 1352 | + // Vigilante_Settings::list_or_shipped(): an empty list of a setting with | |
| 1353 | + // no field on screen is a leftover, not a choice. See its docblock. | |
| 1354 | + $affected_roles = Vigilante_Settings::list_or_shipped( | |
| 1355 | + $settings['affected_roles'] ?? array(), | |
| 1356 | + array( 'user_security', 'registration_approval', 'affected_roles' ) | |
| 1357 | + ); | |
| 1294 | 1358 | |
| 1295 | 1359 | // Check if user role requires approval |
| 1296 | 1360 | $user_roles = $user->roles; |
| 1297 | 1361 | $needs_approval = array_intersect( $user_roles, $affected_roles ); |
| @@ -1299,11 +1363,11 @@ | ||
| 1299 | 1363 | if ( empty( $needs_approval ) ) { |
| 1300 | 1364 | return; |
| 1301 | 1365 | } |
| 1302 | 1366 | |
| 1303 | - // Set pending status | |
| 1304 | - update_user_meta( $user_id, 'vigilante_pending_approval', true ); | |
| 1305 | - update_user_meta( $user_id, 'vigilante_pending_since', time() ); | |
| 1367 | + // Set pending status, on this site only (see site_user_meta_key()). | |
| 1368 | + update_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_approval' ), true ); | |
| 1369 | + update_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_since' ), time() ); | |
| 1306 | 1370 | |
| 1307 | 1371 | // Log |
| 1308 | 1372 | if ( $this->activity_log ) { |
| 1309 | 1373 | $this->activity_log->log( |
| @@ -1336,14 +1400,9 @@ | ||
| 1336 | 1400 | if ( is_wp_error( $user ) ) { |
| 1337 | 1401 | return $user; |
| 1338 | 1402 | } |
| 1339 | 1403 | |
| 1340 | - $is_pending = get_user_meta( $user->ID, 'vigilante_pending_approval', true ); | |
| 1341 | - | |
| 1342 | - if ( $is_pending ) { | |
| 1343 | - // Mark this as a controlled rejection (not a brute force attempt) | |
| 1344 | - add_filter( 'vigilante_skip_failed_login_count', '__return_true' ); | |
| 1345 | - | |
| 1404 | + if ( self::is_pending_anywhere( $user->ID ) ) { | |
| 1346 | 1405 | return new WP_Error( |
| 1347 | 1406 | 'pending_approval', |
| 1348 | 1407 | __( '<strong>Account pending:</strong> Your account is awaiting administrator approval. You will receive an email once approved.', 'vigilante' ) |
| 1349 | 1408 | ); |
| @@ -1383,9 +1442,9 @@ | ||
| 1383 | 1442 | $count, |
| 1384 | 1443 | 'vigilante' |
| 1385 | 1444 | ) ), |
| 1386 | 1445 | absint( $count ), |
| 1387 | - '<a href="' . esc_url( admin_url( 'admin.php?page=vigilante&tab=users' ) ) . '">' . esc_html__( 'Review in Vigilant', 'vigilante' ) . '</a>' | |
| 1446 | + '<a href="' . esc_url( admin_url( 'admin.php?page=vigilante&tab=users#vigilante-section-users-pending' ) ) . '">' . esc_html__( 'Review in Vigilant', 'vigilante' ) . '</a>' | |
| 1388 | 1447 | ); |
| 1389 | 1448 | ?> |
| 1390 | 1449 | </p> |
| 1391 | 1450 | </div> |
| @@ -1392,16 +1451,131 @@ | ||
| 1392 | 1451 | <?php |
| 1393 | 1452 | } |
| 1394 | 1453 | |
| 1395 | 1454 | /** |
| 1455 | + * A user meta key that belongs to one site, even on a network | |
| 1456 | + * | |
| 1457 | + * Registration approval is a per-site setting, but user meta is network | |
| 1458 | + * wide, so a single global key made the pending queue shared: an | |
| 1459 | + * administrator of one site saw, approved and rejected accounts waiting on | |
| 1460 | + * another, and clearing the flag cleared it for the whole network. Reported | |
| 1461 | + * by the wp.org automated review of 2.11.9. | |
| 1462 | + * | |
| 1463 | + * On a network the key carries the blog prefix, the way core does with | |
| 1464 | + * capabilities (wp_2_capabilities), so each site keeps its own queue. On a | |
| 1465 | + * single site the key is returned unchanged, so nothing has to be migrated | |
| 1466 | + * there and the stored data of every existing install keeps working. | |
| 1467 | + * | |
| 1468 | + * @since 2.11.10 | |
| 1469 | + * | |
| 1470 | + * Note the default is null and not 0: wpdb::get_blog_prefix() reads null as | |
| 1471 | + * "the current blog", and 0 as the main site, so passing 0 here gave every | |
| 1472 | + * subsite the key of the main site and kept the queue shared. Caught by | |
| 1473 | + * matriz-red-repaso-21110.sh before this shipped. | |
| 1474 | + * | |
| 1475 | + * @param string $key Base meta key. | |
| 1476 | + * @param int|null $blog_id Blog to build it for. Current blog when null. | |
| 1477 | + * @return string | |
| 1478 | + */ | |
| 1479 | + public static function site_user_meta_key( $key, $blog_id = null ) { | |
| 1480 | + global $wpdb; | |
| 1481 | + | |
| 1482 | + if ( ! is_multisite() ) { | |
| 1483 | + return $key; | |
| 1484 | + } | |
| 1485 | + | |
| 1486 | + return $wpdb->get_blog_prefix( $blog_id ) . $key; | |
| 1487 | + } | |
| 1488 | + | |
| 1489 | + /** | |
| 1490 | + * Whether this account is waiting for approval on ANY site of the network | |
| 1491 | + * | |
| 1492 | + * The queue is per site and stays per site, because approving somebody is a | |
| 1493 | + * decision of the site they signed up to. Blocking them is a different | |
| 1494 | + * question with a different answer, and giving it the same one was a hole: | |
| 1495 | + * the session cookie WordPress issues is valid on every host of the network | |
| 1496 | + * (COOKIE_DOMAIN and COOKIEPATH, wp-includes/ms-default-constants.php:58-59 | |
| 1497 | + * and :84-88), so an account held back on demo1 logged in through the main | |
| 1498 | + * site, where it carried no flag, and walked straight back into demo1 with | |
| 1499 | + * that cookie. Reproduced over HTTP by the second cross review of 2.11.10. | |
| 1500 | + * It is the same reasoning that two_factor_required_for() already applies: | |
| 1501 | + * network-wide cookie, network-wide enforcement. | |
| 1502 | + * | |
| 1503 | + * Read from the account's own meta in one pass rather than by asking site by | |
| 1504 | + * site, so the cost does not grow with the network. The legacy key with no | |
| 1505 | + * prefix is included because the migration that moves it runs on the first | |
| 1506 | + * admin page load and until then a waiting account has to keep being | |
| 1507 | + * blocked; reading both fails closed. | |
| 1508 | + * | |
| 1509 | + * @since 2.11.10 | |
| 1510 | + * | |
| 1511 | + * @param int $user_id User ID. | |
| 1512 | + * @return bool | |
| 1513 | + */ | |
| 1514 | + public static function is_pending_anywhere( $user_id ) { | |
| 1515 | + global $wpdb; | |
| 1516 | + | |
| 1517 | + if ( get_user_meta( $user_id, 'vigilante_pending_approval', true ) ) { | |
| 1518 | + return true; | |
| 1519 | + } | |
| 1520 | + | |
| 1521 | + if ( ! is_multisite() ) { | |
| 1522 | + return false; | |
| 1523 | + } | |
| 1524 | + | |
| 1525 | + $all = get_user_meta( $user_id ); | |
| 1526 | + | |
| 1527 | + if ( ! is_array( $all ) ) { | |
| 1528 | + return false; | |
| 1529 | + } | |
| 1530 | + | |
| 1531 | + $pattern = '/^' . preg_quote( $wpdb->base_prefix, '/' ) . '(\d+_)?vigilante_pending_approval$/'; | |
| 1532 | + | |
| 1533 | + foreach ( $all as $key => $values ) { | |
| 1534 | + if ( ! preg_match( $pattern, $key, $m ) ) { | |
| 1535 | + continue; | |
| 1536 | + } | |
| 1537 | + | |
| 1538 | + /* | |
| 1539 | + * A mark left behind by a site that no longer exists asks nobody for | |
| 1540 | + * anything: deleting a subsite does not touch this plugin's user meta, | |
| 1541 | + * so the account stayed blocked on the whole network with no queue | |
| 1542 | + * anywhere to clear it from, in a plugin whose users have no WP-CLI. | |
| 1543 | + * Found by the third cross review of 2.11.10. get_site() is cached, so | |
| 1544 | + * this costs nothing in the usual case of no leftovers. | |
| 1545 | + */ | |
| 1546 | + if ( ! empty( $m[1] ) && ! get_site( (int) rtrim( $m[1], '_' ) ) ) { | |
| 1547 | + continue; | |
| 1548 | + } | |
| 1549 | + | |
| 1550 | + foreach ( (array) $values as $value ) { | |
| 1551 | + if ( ! empty( $value ) ) { | |
| 1552 | + return true; | |
| 1553 | + } | |
| 1554 | + } | |
| 1555 | + } | |
| 1556 | + | |
| 1557 | + return false; | |
| 1558 | + } | |
| 1559 | + | |
| 1560 | + /** | |
| 1396 | 1561 | * Get pending users |
| 1397 | 1562 | * |
| 1563 | + * The meta key is what scopes this list to the current site, so on a network | |
| 1564 | + * the query deliberately does not add the site's own membership filter on | |
| 1565 | + * top. Core's WP_User_Query turns the default into "{$prefix}capabilities | |
| 1566 | + * EXISTS" (wp-includes/class-wp-user-query.php:598-604), and an account that | |
| 1567 | + * is waiting for approval can perfectly well have no role yet: it then held | |
| 1568 | + * this site's flag, was blocked from logging in, and appeared in no queue at | |
| 1569 | + * all, so nobody could ever approve or reject it. Found by the second cross | |
| 1570 | + * review of 2.11.10. | |
| 1571 | + * | |
| 1398 | 1572 | * @return array Array of pending user objects. |
| 1399 | 1573 | */ |
| 1400 | 1574 | public function get_pending_users() { |
| 1401 | 1575 | // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Limited results in admin context. |
| 1402 | 1576 | $args = array( |
| 1403 | - 'meta_key' => 'vigilante_pending_approval', | |
| 1577 | + 'meta_key' => self::site_user_meta_key( 'vigilante_pending_approval' ), | |
| 1404 | 1578 | 'meta_value' => '1', |
| 1405 | 1579 | 'orderby' => 'registered', |
| 1406 | 1580 | 'order' => 'DESC', |
| 1407 | 1581 | ); |
| @@ -1406,8 +1580,12 @@ | ||
| 1406 | 1580 | 'order' => 'DESC', |
| 1407 | 1581 | ); |
| 1408 | 1582 | // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value |
| 1409 | 1583 | |
| 1584 | + if ( is_multisite() ) { | |
| 1585 | + $args['blog_id'] = 0; | |
| 1586 | + } | |
| 1587 | + | |
| 1410 | 1588 | return get_users( $args ); |
| 1411 | 1589 | } |
| 1412 | 1590 | |
| 1413 | 1591 | /** |
| @@ -1417,17 +1595,28 @@ | ||
| 1417 | 1595 | * @param int $approved_by Admin user ID who approved. |
| 1418 | 1596 | * @return bool |
| 1419 | 1597 | */ |
| 1420 | 1598 | public function approve_user( $user_id, $approved_by = 0 ) { |
| 1599 | + // Same reasoning as reject_user(): approving an account that never asked | |
| 1600 | + // for approval is a no-op that reports success and writes misleading meta. | |
| 1601 | + // Only this site's flag counts, so approving never clears the queue of | |
| 1602 | + // another site of the network (see site_user_meta_key()). | |
| 1603 | + if ( ! get_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_approval' ), true ) ) { | |
| 1604 | + return false; | |
| 1605 | + } | |
| 1606 | + | |
| 1421 | 1607 | $user = get_userdata( $user_id ); |
| 1422 | 1608 | if ( ! $user ) { |
| 1423 | 1609 | return false; |
| 1424 | 1610 | } |
| 1425 | 1611 | |
| 1426 | - delete_user_meta( $user_id, 'vigilante_pending_approval' ); | |
| 1427 | - delete_user_meta( $user_id, 'vigilante_pending_since' ); | |
| 1428 | - update_user_meta( $user_id, 'vigilante_approved_by', $approved_by ); | |
| 1429 | - update_user_meta( $user_id, 'vigilante_approved_date', time() ); | |
| 1612 | + delete_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_approval' ) ); | |
| 1613 | + delete_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_since' ) ); | |
| 1614 | + // Por sitio como las dos de arriba: quien aprueba y cuando es un hecho de | |
| 1615 | + // la cola de ESTE sitio, y dejarlas globales hacia que una aprobacion | |
| 1616 | + // pisara el registro de otro (cierra B4 de la revision cruzada). | |
| 1617 | + update_user_meta( $user_id, self::site_user_meta_key( 'vigilante_approved_by' ), $approved_by ); | |
| 1618 | + update_user_meta( $user_id, self::site_user_meta_key( 'vigilante_approved_date' ), time() ); | |
| 1430 | 1619 | |
| 1431 | 1620 | // Log |
| 1432 | 1621 | if ( $this->activity_log ) { |
| 1433 | 1622 | $admin = $approved_by ? get_userdata( $approved_by ) : null; |
| @@ -1464,8 +1653,16 @@ | ||
| 1464 | 1653 | if ( ! $user ) { |
| 1465 | 1654 | return false; |
| 1466 | 1655 | } |
| 1467 | 1656 | |
| 1657 | + // Only an account actually waiting for approval may be rejected. Without | |
| 1658 | + // this the handler deletes any user id it is given, and wp_delete_user() | |
| 1659 | + // with no reassignment takes their posts with them, skipping the dialog | |
| 1660 | + // core always shows. Deleting a member is the Users screen's job. | |
| 1661 | + if ( ! get_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_approval' ), true ) ) { | |
| 1662 | + return false; | |
| 1663 | + } | |
| 1664 | + | |
| 1468 | 1665 | // Log before deletion |
| 1469 | 1666 | if ( $this->activity_log ) { |
| 1470 | 1667 | $admin = $rejected_by ? get_userdata( $rejected_by ) : null; |
| 1471 | 1668 | $this->activity_log->log( |
| @@ -1489,8 +1686,21 @@ | ||
| 1489 | 1686 | |
| 1490 | 1687 | // Send rejection email before deleting |
| 1491 | 1688 | $this->send_rejection_email( $user, $reason ); |
| 1492 | 1689 | |
| 1690 | + /* | |
| 1691 | + * The mark goes first, because on a network the account may well survive | |
| 1692 | + * the deletion: wp_delete_user() only calls remove_user_from_blog() there | |
| 1693 | + * (wp-admin/includes/user.php:440-442), which clears the role and nothing | |
| 1694 | + * of this plugin's own meta. Leaving it behind made Reject a loop with no | |
| 1695 | + * way out: the account stayed blocked on every site of the network, the | |
| 1696 | + * row never left the queue (which since 2.11.10 no longer hides accounts | |
| 1697 | + * without a role), and pressing Reject again sent the rejection email once | |
| 1698 | + * more and reported success. Found by the third cross review of 2.11.10. | |
| 1699 | + */ | |
| 1700 | + delete_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_approval' ) ); | |
| 1701 | + delete_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_since' ) ); | |
| 1702 | + | |
| 1493 | 1703 | // Delete user |
| 1494 | 1704 | require_once ABSPATH . 'wp-admin/includes/user.php'; |
| 1495 | 1705 | return wp_delete_user( $user_id ); |
| 1496 | 1706 | } |
| @@ -1509,9 +1719,9 @@ | ||
| 1509 | 1719 | __( '[%s] New user registration pending approval', 'vigilante' ), |
| 1510 | 1720 | $site_name |
| 1511 | 1721 | ); |
| 1512 | 1722 | |
| 1513 | - $approve_url = admin_url( 'admin.php?page=vigilante&tab=users' ); | |
| 1723 | + $approve_url = admin_url( 'admin.php?page=vigilante&tab=users#vigilante-section-users-pending' ); | |
| 1514 | 1724 | |
| 1515 | 1725 | $body = Vigilante_Email_Template::p( __( 'A new user has registered and is awaiting your approval.', 'vigilante' ) ); |
| 1516 | 1726 | $body .= Vigilante_Email_Template::data_table( array( |
| 1517 | 1727 | __( 'Username', 'vigilante' ) => $user->user_login, |
| @@ -1872,8 +2082,31 @@ | ||
| 1872 | 2082 | return max( 0, $count ); |
| 1873 | 2083 | } |
| 1874 | 2084 | |
| 1875 | 2085 | /** |
| 2086 | + * Whether the session store this limit would act on belongs to a whole network | |
| 2087 | + * | |
| 2088 | + * WP_Session_Tokens keeps session_tokens in the usermeta table, which is | |
| 2089 | + * network wide, while this limit is configured per site. So on a network a | |
| 2090 | + * site administrator setting a low limit would count, and with close_oldest | |
| 2091 | + * close, the sessions the same user opened on other sites, including an | |
| 2092 | + * administrator session elsewhere; and block_new would refuse a login over | |
| 2093 | + * sessions that have nothing to do with this site. Reported by the wp.org | |
| 2094 | + * automated review of 2.11.9, on the close_oldest half. | |
| 2095 | + * | |
| 2096 | + * Until the network-wide policy of 3.1.0, the limit simply does not apply on | |
| 2097 | + * a network, and the settings screen says so. On a single site nothing | |
| 2098 | + * changes: there the session store and the setting cover the same thing. | |
| 2099 | + * | |
| 2100 | + * @since 2.11.10 | |
| 2101 | + * | |
| 2102 | + * @return bool | |
| 2103 | + */ | |
| 2104 | + public static function session_limit_is_network_wide() { | |
| 2105 | + return is_multisite(); | |
| 2106 | + } | |
| 2107 | + | |
| 2108 | + /** | |
| 1876 | 2109 | * Check session limit before login completes (for block_new behavior) |
| 1877 | 2110 | * |
| 1878 | 2111 | * @param WP_User $user User object. |
| 1879 | 2112 | * @param string $password Password. |
| @@ -1883,8 +2116,12 @@ | ||
| 1883 | 2116 | if ( is_wp_error( $user ) ) { |
| 1884 | 2117 | return $user; |
| 1885 | 2118 | } |
| 1886 | 2119 | |
| 2120 | + if ( self::session_limit_is_network_wide() ) { | |
| 2121 | + return $user; | |
| 2122 | + } | |
| 2123 | + | |
| 1887 | 2124 | $settings = $this->options['session_limits'] ?? array(); |
| 1888 | 2125 | $max_sessions = absint( $settings['max_sessions'] ?? 3 ); |
| 1889 | 2126 | $exclude_admins = ! empty( $settings['exclude_admins'] ); |
| 1890 | 2127 | |
| @@ -1914,11 +2151,8 @@ | ||
| 1914 | 2151 | 'warning' |
| 1915 | 2152 | ); |
| 1916 | 2153 | } |
| 1917 | 2154 | |
| 1918 | - // Mark this as a controlled rejection (not a brute force attempt) | |
| 1919 | - add_filter( 'vigilante_skip_failed_login_count', '__return_true' ); | |
| 1920 | - | |
| 1921 | 2155 | return new WP_Error( |
| 1922 | 2156 | 'session_limit_exceeded', |
| 1923 | 2157 | sprintf( |
| 1924 | 2158 | /* translators: %d: Maximum sessions allowed */ |
| @@ -1937,8 +2171,12 @@ | ||
| 1937 | 2171 | * @param string $user_login Username. |
| 1938 | 2172 | * @param WP_User $user User object. |
| 1939 | 2173 | */ |
| 1940 | 2174 | public function enforce_session_limit( $user_login, $user ) { |
| 2175 | + if ( self::session_limit_is_network_wide() ) { | |
| 2176 | + return; | |
| 2177 | + } | |
| 2178 | + | |
| 1941 | 2179 | $settings = $this->options['session_limits'] ?? array(); |
| 1942 | 2180 | $max_sessions = absint( $settings['max_sessions'] ?? 3 ); |
| 1943 | 2181 | $behavior = $settings['behavior'] ?? 'block_new'; |
| 1944 | 2182 | $exclude_admins = ! empty( $settings['exclude_admins'] ); |
| @@ -1957,27 +2195,69 @@ | ||
| 1957 | 2195 | return; |
| 1958 | 2196 | } |
| 1959 | 2197 | |
| 1960 | 2198 | if ( 'close_oldest' === $behavior ) { |
| 1961 | - // Sort by login time and destroy oldest | |
| 1962 | - uasort( $all_sessions, function( $a, $b ) { | |
| 1963 | - return ( $a['login'] ?? 0 ) - ( $b['login'] ?? 0 ); | |
| 2199 | + /* | |
| 2200 | + * Remove the oldest sessions by editing the session store directly. | |
| 2201 | + * | |
| 2202 | + * WP_Session_Tokens::get_all() returns array_values( get_sessions() ), | |
| 2203 | + * so its keys are 0, 1, 2, not tokens, and destroy() expects a raw | |
| 2204 | + * token, which is not stored anywhere and cannot be recovered for a | |
| 2205 | + * session other than the current one. Until 2.11.9 the loop passed | |
| 2206 | + * those numeric keys to destroy(), which hashed them, matched nothing | |
| 2207 | + * and closed no session while still counting and logging success, so | |
| 2208 | + * the cap did nothing under close_oldest. Reported by the wp.org | |
| 2209 | + * automated review of 2.11.8. | |
| 2210 | + * | |
| 2211 | + * The store keeps the sessions as the user meta 'session_tokens', | |
| 2212 | + * keyed by the verifier hash( 'sha256', token ), which is the value | |
| 2213 | + * is_current_session() already compares against. So the oldest are | |
| 2214 | + * removed from that map, keeping the current session whatever its age. | |
| 2215 | + * On a network the meta is global (one finding of the multisite audit, | |
| 2216 | + * to be reworked in 3.1.0); here the fix is only to make the removal | |
| 2217 | + * actually happen. | |
| 2218 | + */ | |
| 2219 | + $stored = get_user_meta( $user->ID, 'session_tokens', true ); | |
| 2220 | + | |
| 2221 | + if ( ! is_array( $stored ) || empty( $stored ) ) { | |
| 2222 | + return; | |
| 2223 | + } | |
| 2224 | + | |
| 2225 | + $now = time(); | |
| 2226 | + $changed = false; | |
| 2227 | + | |
| 2228 | + // Expired sessions are dead weight and count for nothing; drop them first. | |
| 2229 | + foreach ( $stored as $verifier => $session ) { | |
| 2230 | + if ( isset( $session['expiration'] ) && (int) $session['expiration'] < $now ) { | |
| 2231 | + unset( $stored[ $verifier ] ); | |
| 2232 | + $changed = true; | |
| 2233 | + } | |
| 2234 | + } | |
| 2235 | + | |
| 2236 | + // Oldest first, keeping the current session whatever its login time. | |
| 2237 | + uasort( $stored, function ( $a, $b ) { | |
| 2238 | + return ( $a['login'] ?? 0 ) <=> ( $b['login'] ?? 0 ); | |
| 1964 | 2239 | } ); |
| 1965 | 2240 | |
| 1966 | - $sessions_to_remove = $session_count - $max_sessions; | |
| 1967 | - $removed = 0; | |
| 2241 | + $sessions_to_remove = count( $stored ) - $max_sessions; | |
| 2242 | + $removed = 0; | |
| 1968 | 2243 | |
| 1969 | - foreach ( $all_sessions as $token_hash => $session ) { | |
| 2244 | + foreach ( $stored as $verifier => $session ) { | |
| 1970 | 2245 | if ( $removed >= $sessions_to_remove ) { |
| 1971 | 2246 | break; |
| 1972 | 2247 | } |
| 1973 | - // Don't remove current session | |
| 1974 | - if ( ! $this->is_current_session( $token_hash ) ) { | |
| 1975 | - $sessions->destroy( $token_hash ); | |
| 1976 | - $removed++; | |
| 2248 | + if ( $this->is_current_session( $verifier ) ) { | |
| 2249 | + continue; | |
| 1977 | 2250 | } |
| 2251 | + unset( $stored[ $verifier ] ); | |
| 2252 | + $removed++; | |
| 2253 | + $changed = true; | |
| 1978 | 2254 | } |
| 1979 | 2255 | |
| 2256 | + if ( $changed ) { | |
| 2257 | + update_user_meta( $user->ID, 'session_tokens', $stored ); | |
| 2258 | + } | |
| 2259 | + | |
| 1980 | 2260 | // Log |
| 1981 | 2261 | if ( $this->activity_log && $removed > 0 ) { |
| 1982 | 2262 | $this->activity_log->log( |
| 1983 | 2263 | 'user', |
| @@ -2027,9 +2307,15 @@ | ||
| 2027 | 2307 | |
| 2028 | 2308 | // Honor both affected_roles AND the per-user exclusion list, and |
| 2029 | 2309 | // clear stale flags if the user no longer matches the rules. |
| 2030 | 2310 | if ( ! $this->is_password_expiration_applicable( $user_id ) ) { |
| 2031 | - if ( get_user_meta( $user_id, 'vigilante_must_change_password', true ) ) { | |
| 2311 | + // Only on a single site, for the same reason as in | |
| 2312 | + // force_password_change_redirect(): on a network the flag belongs to | |
| 2313 | + // the account, and this site's policy says nothing about the site | |
| 2314 | + // that set it. The 2.11.8 fix only covered that method, and this | |
| 2315 | + // notice cleared the flag anyway on the next admin page; found by | |
| 2316 | + // the cross review of 2.11.8. | |
| 2317 | + if ( ! is_multisite() && get_user_meta( $user_id, 'vigilante_must_change_password', true ) ) { | |
| 2032 | 2318 | delete_user_meta( $user_id, 'vigilante_must_change_password' ); |
| 2033 | 2319 | } |
| 2034 | 2320 | return; |
| 2035 | 2321 | } |
| @@ -2092,39 +2378,121 @@ | ||
| 2092 | 2378 | } |
| 2093 | 2379 | } |
| 2094 | 2380 | |
| 2095 | 2381 | /** |
| 2096 | - * Force redirect to password change page | |
| 2382 | + * Whether this user must change an expired password before doing anything else | |
| 2383 | + * | |
| 2384 | + * The flag alone is not enough: it is re-checked against the current policy, | |
| 2385 | + * because the admin may have taken the user's role out of affected_roles or | |
| 2386 | + * added the user to the exclusion list after it was set, which would | |
| 2387 | + * otherwise lock them in a redirect loop. A stale flag is cleared on a | |
| 2388 | + * single site; on a network the meta is shared by every site and the policy | |
| 2389 | + * checked is only this site's, so it is left alone and simply not enforced | |
| 2390 | + * here (a network-wide rework is the 3.1.0 multisite item). | |
| 2391 | + * | |
| 2392 | + * @since 2.11.9 | |
| 2393 | + * | |
| 2394 | + * @param int $user_id User ID. | |
| 2395 | + * @return bool | |
| 2097 | 2396 | */ |
| 2397 | + private function must_change_password( $user_id ) { | |
| 2398 | + if ( ! $user_id ) { | |
| 2399 | + return false; | |
| 2400 | + } | |
| 2401 | + | |
| 2402 | + $flagged = (bool) get_user_meta( $user_id, 'vigilante_must_change_password', true ); | |
| 2403 | + | |
| 2404 | + if ( ! $this->is_password_expiration_applicable( $user_id ) ) { | |
| 2405 | + if ( $flagged && ! is_multisite() ) { | |
| 2406 | + delete_user_meta( $user_id, 'vigilante_must_change_password' ); | |
| 2407 | + } | |
| 2408 | + return false; | |
| 2409 | + } | |
| 2410 | + | |
| 2411 | + if ( $flagged ) { | |
| 2412 | + return true; | |
| 2413 | + } | |
| 2414 | + | |
| 2415 | + // The flag is set at interactive login (check_password_expiration on | |
| 2416 | + // wp_login). A session that authenticates only through REST, XML-RPC or | |
| 2417 | + // an application password never fires wp_login, so the flag can be | |
| 2418 | + // absent while the password is in fact expired. Compute it on the fly | |
| 2419 | + // too, so a non-interactive route is not a way around the block. The | |
| 2420 | + // computation self-seeds the change date on first sight and never locks | |
| 2421 | + // out a user who has no record yet (see is_password_expired()). | |
| 2422 | + return $this->is_password_expired( $user_id ); | |
| 2423 | + } | |
| 2424 | + | |
| 2425 | + /** | |
| 2426 | + * Force a user with an expired password to change it, on wp-admin and AJAX | |
| 2427 | + * | |
| 2428 | + * Until 2.11.9 this only redirected wp-admin pages and skipped AJAX, so an | |
| 2429 | + * expired-password session kept working through admin-ajax, and the REST API | |
| 2430 | + * and the front end were not covered at all. The wp.org automated review of | |
| 2431 | + * 2.11.8 flagged it: setting a flag on login is not enforcement if the flag | |
| 2432 | + * is only read by one redirect. It is now enforced on every entry point, | |
| 2433 | + * here for wp-admin and AJAX and in the three methods below for REST, the | |
| 2434 | + * front end and XML-RPC. The only thing an affected user can still do is | |
| 2435 | + * change the password on profile.php or log out. | |
| 2436 | + */ | |
| 2098 | 2437 | public function force_password_change_redirect() { |
| 2099 | - if ( ! is_user_logged_in() ) { | |
| 2438 | + if ( ! is_user_logged_in() || ! $this->must_change_password( get_current_user_id() ) ) { | |
| 2100 | 2439 | return; |
| 2101 | 2440 | } |
| 2102 | 2441 | |
| 2103 | - // Don't redirect on AJAX or profile page | |
| 2442 | + // AJAX: a redirect is useless, so the request is refused. Changing the | |
| 2443 | + // password is a profile.php form POST, not AJAX, so nothing the user | |
| 2444 | + // needs to fix this is blocked. | |
| 2104 | 2445 | if ( wp_doing_ajax() ) { |
| 2105 | - return; | |
| 2446 | + wp_send_json_error( | |
| 2447 | + array( 'message' => __( 'Your password has expired. Change it in your profile before continuing.', 'vigilante' ) ), | |
| 2448 | + 403 | |
| 2449 | + ); | |
| 2106 | 2450 | } |
| 2107 | 2451 | |
| 2452 | + // profile.php is where the change happens; do not redirect it onto itself. | |
| 2108 | 2453 | global $pagenow; |
| 2109 | 2454 | if ( 'profile.php' === $pagenow ) { |
| 2110 | 2455 | return; |
| 2111 | 2456 | } |
| 2112 | 2457 | |
| 2113 | - $user_id = get_current_user_id(); | |
| 2114 | - $must_change = get_user_meta( $user_id, 'vigilante_must_change_password', true ); | |
| 2458 | + wp_safe_redirect( admin_url( 'profile.php#password' ) ); | |
| 2459 | + exit; | |
| 2460 | + } | |
| 2115 | 2461 | |
| 2116 | - if ( ! $must_change ) { | |
| 2117 | - return; | |
| 2462 | + /** | |
| 2463 | + * Refuse REST API requests from a user whose password has expired | |
| 2464 | + * | |
| 2465 | + * @since 2.11.9 | |
| 2466 | + * | |
| 2467 | + * @param WP_Error|null|true $result Result of the earlier authentication checks. | |
| 2468 | + * @return WP_Error|null|true | |
| 2469 | + */ | |
| 2470 | + public function block_expired_password_rest( $result ) { | |
| 2471 | + // Leave any decision another check already made, and do not act on | |
| 2472 | + // logged-out requests to public endpoints. | |
| 2473 | + if ( null !== $result && false !== $result ) { | |
| 2474 | + return $result; | |
| 2118 | 2475 | } |
| 2119 | 2476 | |
| 2120 | - // Re-validate against current settings: the admin may have removed | |
| 2121 | - // this user's role from affected_roles or added the user to the | |
| 2122 | - // excluded list after the flag was set. Without this check the flag | |
| 2123 | - // outlives the configuration change and locks the user in a redirect | |
| 2124 | - // loop into profile.php. | |
| 2125 | - if ( ! $this->is_password_expiration_applicable( $user_id ) ) { | |
| 2126 | - delete_user_meta( $user_id, 'vigilante_must_change_password' ); | |
| 2477 | + if ( is_user_logged_in() && $this->must_change_password( get_current_user_id() ) ) { | |
| 2478 | + return new WP_Error( | |
| 2479 | + 'vigilante_password_expired', | |
| 2480 | + __( 'Your password has expired. Change it in your profile before using the REST API.', 'vigilante' ), | |
| 2481 | + array( 'status' => 403 ) | |
| 2482 | + ); | |
| 2483 | + } | |
| 2484 | + | |
| 2485 | + return $result; | |
| 2486 | + } | |
| 2487 | + | |
| 2488 | + /** | |
| 2489 | + * Send a user with an expired password to the change page from the front end | |
| 2490 | + * | |
| 2491 | + * @since 2.11.9 | |
| 2492 | + */ | |
| 2493 | + public function force_password_change_frontend() { | |
| 2494 | + if ( is_admin() || ! is_user_logged_in() || ! $this->must_change_password( get_current_user_id() ) ) { | |
| 2127 | 2495 | return; |
| 2128 | 2496 | } |
| 2129 | 2497 | |
| 2130 | 2498 | wp_safe_redirect( admin_url( 'profile.php#password' ) ); |
| @@ -2131,8 +2499,40 @@ | ||
| 2131 | 2499 | exit; |
| 2132 | 2500 | } |
| 2133 | 2501 | |
| 2134 | 2502 | /** |
| 2503 | + * Refuse XML-RPC calls from a user whose password has expired | |
| 2504 | + * | |
| 2505 | + * The last of the four non-wp-admin entry points. XML-RPC authenticates on | |
| 2506 | + * every call with the account credentials (a password or an application | |
| 2507 | + * password), so a session that never touches wp-admin could keep acting | |
| 2508 | + * through xmlrpc.php while the password sits expired. Scoped to XML-RPC | |
| 2509 | + * requests so an ordinary login, which the user needs to reach profile.php, | |
| 2510 | + * is never blocked here. Runs late on authenticate, after core and the | |
| 2511 | + * application-password handler have resolved the user. | |
| 2512 | + * | |
| 2513 | + * @since 2.11.9 | |
| 2514 | + * | |
| 2515 | + * @param WP_User|WP_Error|null $user Result of the earlier authentication. | |
| 2516 | + * @return WP_User|WP_Error|null | |
| 2517 | + */ | |
| 2518 | + public function block_expired_password_xmlrpc( $user ) { | |
| 2519 | + if ( ! ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) ) { | |
| 2520 | + return $user; | |
| 2521 | + } | |
| 2522 | + | |
| 2523 | + if ( $user instanceof WP_User && $this->must_change_password( $user->ID ) ) { | |
| 2524 | + return new WP_Error( | |
| 2525 | + 'vigilante_password_expired', | |
| 2526 | + __( 'Your password has expired. Change it in your profile before using XML-RPC.', 'vigilante' ), | |
| 2527 | + array( 'status' => 403 ) | |
| 2528 | + ); | |
| 2529 | + } | |
| 2530 | + | |
| 2531 | + return $user; | |
| 2532 | + } | |
| 2533 | + | |
| 2534 | + /** | |
| 2135 | 2535 | * Whether password expiration rules currently apply to a given user |
| 2136 | 2536 | * |
| 2137 | 2537 | * Used to detect stale flags after the admin changes affected_roles or |
| 2138 | 2538 | * the per-user exclusion list. |
| @@ -2496,8 +2896,21 @@ | ||
| 2496 | 2896 | * |
| 2497 | 2897 | * @param int $user_id User ID. |
| 2498 | 2898 | */ |
| 2499 | 2899 | public function send_verification_email( $user_id ) { |
| 2900 | + /* | |
| 2901 | + * Never send an account that is already verified back to pending. The | |
| 2902 | + * resend link below reaches this, and while the pending value was | |
| 2903 | + * unreadable (see the note on the meta write) that was harmless; with | |
| 2904 | + * the check working, resending for a verified account would lock its | |
| 2905 | + * owner out of their own site. | |
| 2906 | + */ | |
| 2907 | + if ( metadata_exists( 'user', $user_id, 'vigilante_email_verified' ) | |
| 2908 | + && get_user_meta( $user_id, 'vigilante_email_verified', true ) | |
| 2909 | + ) { | |
| 2910 | + return; | |
| 2911 | + } | |
| 2912 | + | |
| 2500 | 2913 | $user = get_userdata( $user_id ); |
| 2501 | 2914 | if ( ! $user ) { |
| 2502 | 2915 | return; |
| 2503 | 2916 | } |
| @@ -2512,10 +2925,22 @@ | ||
| 2512 | 2925 | |
| 2513 | 2926 | // Store token |
| 2514 | 2927 | update_user_meta( $user_id, 'vigilante_verification_token', $token_hash ); |
| 2515 | 2928 | update_user_meta( $user_id, 'vigilante_verification_expires', $expires ); |
| 2516 | - update_user_meta( $user_id, 'vigilante_email_verified', false ); | |
| 2517 | 2929 | |
| 2930 | + /* | |
| 2931 | + * '0' and not false. update_user_meta() stores false as an empty string | |
| 2932 | + * (maybe_serialize() returns it unchanged and wpdb writes it with %s), and | |
| 2933 | + * an empty string is what get_user_meta() also returns when there is no | |
| 2934 | + * row at all. So from the moment this feature existed until 2.11.10 the | |
| 2935 | + * value written to mean "not verified yet" was read back as "this account | |
| 2936 | + * predates the feature, let it in", and the branch that blocks the login | |
| 2937 | + * was unreachable. Found by the file-by-file review of 2.11.10. '0' is | |
| 2938 | + * falsy in PHP and survives the round trip, and the readers below tell an | |
| 2939 | + * absent row from a stored one with metadata_exists(). | |
| 2940 | + */ | |
| 2941 | + update_user_meta( $user_id, 'vigilante_email_verified', '0' ); | |
| 2942 | + | |
| 2518 | 2943 | // Build verification URL |
| 2519 | 2944 | $verify_url = add_query_arg( |
| 2520 | 2945 | array( |
| 2521 | 2946 | 'vigilante_verify' => '1', |
| @@ -2589,16 +3014,21 @@ | ||
| 2589 | 3014 | if ( is_wp_error( $user ) ) { |
| 2590 | 3015 | return $user; |
| 2591 | 3016 | } |
| 2592 | 3017 | |
| 2593 | - // Check if email is verified | |
| 2594 | - $verified = get_user_meta( $user->ID, 'vigilante_email_verified', true ); | |
| 2595 | - | |
| 2596 | - // If no meta exists, user was created before this feature - allow | |
| 2597 | - if ( '' === $verified ) { | |
| 3018 | + /* | |
| 3019 | + * Only a row that does not exist means "created before this feature". | |
| 3020 | + * An existing row holding an empty string is an account that older | |
| 3021 | + * versions marked as pending, and it has to be blocked like any other: | |
| 3022 | + * reading both the same way is what made this check let everyone in | |
| 3023 | + * (see send_verification_email()). | |
| 3024 | + */ | |
| 3025 | + if ( ! metadata_exists( 'user', $user->ID, 'vigilante_email_verified' ) ) { | |
| 2598 | 3026 | return $user; |
| 2599 | 3027 | } |
| 2600 | 3028 | |
| 3029 | + $verified = get_user_meta( $user->ID, 'vigilante_email_verified', true ); | |
| 3030 | + | |
| 2601 | 3031 | if ( ! $verified ) { |
| 2602 | 3032 | $settings = $this->options['email_verification'] ?? array(); |
| 2603 | 3033 | $allow_resend = ! empty( $settings['allow_resend'] ); |
| 2604 | 3034 | |
| @@ -2640,10 +3070,19 @@ | ||
| 2640 | 3070 | |
| 2641 | 3071 | // Verify nonce to prevent CSRF and user-ID probing. |
| 2642 | 3072 | if ( ! isset( $_GET['_vigilante_nonce'] ) || |
| 2643 | 3073 | ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_vigilante_nonce'] ) ), 'vigilante_resend_verification_' . $user_id ) ) { |
| 2644 | - wp_safe_redirect( add_query_arg( 'vigilante_message', 'invalid', wp_login_url() ) ); | |
| 2645 | - exit; | |
| 3074 | + /* | |
| 3075 | + * Nothing is redirected to the login page until the request | |
| 3076 | + * has proved something, and a bad nonce proves nothing. It | |
| 3077 | + * used to answer with a redirect to wp_login_url(), which | |
| 3078 | + * under a custom login URL IS the secret address, so any | |
| 3079 | + * visitor could read it out of the Location header of a | |
| 3080 | + * request carrying garbage. Found by the third cross review | |
| 3081 | + * of 2.11.10. Returning leaves the request to render the page | |
| 3082 | + * it asked for, which tells nobody anything. | |
| 3083 | + */ | |
| 3084 | + return; | |
| 2646 | 3085 | } |
| 2647 | 3086 | |
| 2648 | 3087 | // Rate limiting: allow 1 resend every 5 minutes per user to prevent email spam. |
| 2649 | 3088 | $transient_key = 'vigilante_resend_' . $user_id; |
| @@ -2664,28 +3103,30 @@ | ||
| 2664 | 3103 | $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0; |
| 2665 | 3104 | // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token-based verification below. |
| 2666 | 3105 | $token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : ''; |
| 2667 | 3106 | |
| 3107 | + // Same as the resend above: no proof, no redirect, so the Location | |
| 3108 | + // header cannot be used to read the custom login URL. | |
| 2668 | 3109 | if ( ! $user_id || ! $token ) { |
| 2669 | - wp_safe_redirect( add_query_arg( 'vigilante_message', 'invalid', wp_login_url() ) ); | |
| 2670 | - exit; | |
| 3110 | + return; | |
| 2671 | 3111 | } |
| 2672 | 3112 | |
| 2673 | - $stored_hash = get_user_meta( $user_id, 'vigilante_verification_token', true ); | |
| 2674 | - $expires = get_user_meta( $user_id, 'vigilante_verification_expires', true ); | |
| 3113 | + $stored_hash = (string) get_user_meta( $user_id, 'vigilante_verification_token', true ); | |
| 3114 | + $expires = (int) get_user_meta( $user_id, 'vigilante_verification_expires', true ); | |
| 2675 | 3115 | |
| 2676 | - // Check expiration | |
| 3116 | + // The token first. Checking the expiry before it answered "expired" for | |
| 3117 | + // any account with no verification pending and "invalid" for one waiting, | |
| 3118 | + // so a wrong link revealed which user ids were waiting (2.11.8). Only the | |
| 3119 | + // holder of the right token learns that it expired. | |
| 3120 | + if ( '' === $stored_hash || ! hash_equals( $stored_hash, wp_hash( $token ) ) ) { | |
| 3121 | + return; | |
| 3122 | + } | |
| 3123 | + | |
| 2677 | 3124 | if ( time() > $expires ) { |
| 2678 | 3125 | wp_safe_redirect( add_query_arg( 'vigilante_message', 'expired', wp_login_url() ) ); |
| 2679 | 3126 | exit; |
| 2680 | 3127 | } |
| 2681 | 3128 | |
| 2682 | - // Verify token | |
| 2683 | - if ( ! hash_equals( $stored_hash, wp_hash( $token ) ) ) { | |
| 2684 | - wp_safe_redirect( add_query_arg( 'vigilante_message', 'invalid', wp_login_url() ) ); | |
| 2685 | - exit; | |
| 2686 | - } | |
| 2687 | - | |
| 2688 | 3129 | // Mark as verified |
| 2689 | 3130 | update_user_meta( $user_id, 'vigilante_email_verified', true ); |
| 2690 | 3131 | delete_user_meta( $user_id, 'vigilante_verification_token' ); |
| 2691 | 3132 | delete_user_meta( $user_id, 'vigilante_verification_expires' ); |
| @@ -2706,12 +3147,11 @@ | ||
| 2706 | 3147 | 'info' |
| 2707 | 3148 | ); |
| 2708 | 3149 | } |
| 2709 | 3150 | |
| 2710 | - // Check if user still needs approval | |
| 2711 | - $is_pending = get_user_meta( $user_id, 'vigilante_pending_approval', true ); | |
| 2712 | - | |
| 2713 | - if ( $is_pending ) { | |
| 3151 | + // Anywhere on the network, so the message matches what will actually | |
| 3152 | + // happen at the login: that is what blocks (see is_pending_anywhere()). | |
| 3153 | + if ( self::is_pending_anywhere( $user_id ) ) { | |
| 2714 | 3154 | // User verified but still pending approval |
| 2715 | 3155 | wp_safe_redirect( |
| 2716 | 3156 | add_query_arg( |
| 2717 | 3157 | array( |
| @@ -2772,15 +3212,17 @@ | ||
| 2772 | 3212 | * @param int $user_id User ID. |
| 2773 | 3213 | * @return bool |
| 2774 | 3214 | */ |
| 2775 | 3215 | public function is_email_verified( $user_id ) { |
| 2776 | - $verified = get_user_meta( $user_id, 'vigilante_email_verified', true ); | |
| 2777 | - | |
| 2778 | - // If no meta exists, consider verified (old users) | |
| 2779 | - if ( '' === $verified ) { | |
| 3216 | + // Same reading as block_unverified_user_login(): only an absent row means | |
| 3217 | + // the account predates the feature. A stored empty string is an account | |
| 3218 | + // an older version left pending. | |
| 3219 | + if ( ! metadata_exists( 'user', $user_id, 'vigilante_email_verified' ) ) { | |
| 2780 | 3220 | return true; |
| 2781 | 3221 | } |
| 2782 | 3222 | |
| 3223 | + $verified = get_user_meta( $user_id, 'vigilante_email_verified', true ); | |
| 3224 | + | |
| 2783 | 3225 | return (bool) $verified; |
| 2784 | 3226 | } |
| 2785 | 3227 | |
| 2786 | 3228 | /* ========================================================================= |
| @@ -2802,9 +3244,9 @@ | ||
| 2802 | 3244 | |
| 2803 | 3245 | // Check if this user's role requires approval |
| 2804 | 3246 | $needs_approval = false; |
| 2805 | 3247 | if ( ! empty( $registration_approval['enabled'] ) ) { |
| 2806 | - $affected_roles = $registration_approval['affected_roles'] ?? array( 'subscriber' ); | |
| 3248 | + $affected_roles = Vigilante_Settings::list_or_shipped( $registration_approval['affected_roles'] ?? array(), array( 'user_security', 'registration_approval', 'affected_roles' ) ); | |
| 2807 | 3249 | $needs_approval = ! empty( array_intersect( $user->roles, $affected_roles ) ); |
| 2808 | 3250 | } |
| 2809 | 3251 | |
| 2810 | 3252 | // Check if email verification is enabled |
| @@ -2916,9 +3358,9 @@ | ||
| 2916 | 3358 | if ( empty( $registration_approval['enabled'] ) ) { |
| 2917 | 3359 | return false; |
| 2918 | 3360 | } |
| 2919 | 3361 | |
| 2920 | - $affected_roles = $registration_approval['affected_roles'] ?? array( 'subscriber' ); | |
| 3362 | + $affected_roles = Vigilante_Settings::list_or_shipped( $registration_approval['affected_roles'] ?? array(), array( 'user_security', 'registration_approval', 'affected_roles' ) ); | |
| 2921 | 3363 | return ! empty( array_intersect( $user->roles, $affected_roles ) ); |
| 2922 | 3364 | } |
| 2923 | 3365 | |
| 2924 | 3366 | /** |