| @@ -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 |
| @@ -1206,22 +1249,27 @@ | ||
| 1206 | 1249 | * @param string $password Password. |
| 1207 | 1250 | * @return WP_User|WP_Error|null |
| 1208 | 1251 | */ |
| 1209 | 1252 | public function check_force_reset_on_login( $user, $username, $password ) { |
| 1210 | - // Resolve the target user. The flag must be evaluated whether the | |
| 1211 | - // credentials matched (WP_User) or not (WP_Error). | |
| 1212 | - if ( $user instanceof WP_User ) { | |
| 1213 | - $login_user = $user; | |
| 1214 | - } else { | |
| 1215 | - $login_user = get_user_by( 'login', $username ); | |
| 1216 | - if ( ! $login_user ) { | |
| 1217 | - $login_user = get_user_by( 'email', $username ); | |
| 1218 | - } | |
| 1253 | + /* | |
| 1254 | + * Only a login that would otherwise have succeeded is turned into this | |
| 1255 | + * rejection. Wrong credentials are left exactly as WordPress reported | |
| 1256 | + * them, and they are counted like any other failed login. | |
| 1257 | + * | |
| 1258 | + * Until 2.11.12 this ran for a WP_Error too, resolving the account from | |
| 1259 | + * the username, and replaced an incorrect_password with the rejection | |
| 1260 | + * below. A rejection of Vigilant's own is not counted towards the brute | |
| 1261 | + * force lockout, so any account with a pending forced reset could be | |
| 1262 | + * guessed at without limit: measured on 17 Sep 2026 against 2.11.11 and | |
| 1263 | + * against the first build of 2.11.12, six wrong passwords in a row, none | |
| 1264 | + * of them counted and no lockout at the end. The message this function | |
| 1265 | + * exists to show belongs to whoever typed the right password. | |
| 1266 | + */ | |
| 1267 | + if ( ! ( $user instanceof WP_User ) ) { | |
| 1268 | + return $user; | |
| 1219 | 1269 | } |
| 1220 | 1270 | |
| 1221 | - if ( ! $login_user ) { | |
| 1222 | - return $user; | |
| 1223 | - } | |
| 1271 | + $login_user = $user; | |
| 1224 | 1272 | |
| 1225 | 1273 | // Check if this user has a pending forced reset. |
| 1226 | 1274 | $force_reset = get_user_meta( $login_user->ID, 'vigilante_force_reset_pending', true ); |
| 1227 | 1275 | if ( ! $force_reset ) { |
| @@ -1227,17 +1275,11 @@ | ||
| 1227 | 1275 | if ( ! $force_reset ) { |
| 1228 | 1276 | return $user; |
| 1229 | 1277 | } |
| 1230 | 1278 | |
| 1231 | - // If credentials were wrong with an error other than incorrect_password | |
| 1232 | - // (e.g. a Vigilant lockout, pending approval), don't shadow it. | |
| 1233 | - if ( is_wp_error( $user ) && ! in_array( 'incorrect_password', $user->get_error_codes(), true ) ) { | |
| 1234 | - return $user; | |
| 1235 | - } | |
| 1279 | + // Not counted towards the brute force lockout: the rejection is | |
| 1280 | + // recognised by its error code (Vigilante_Login_Security::CONTROLLED_REJECTIONS). | |
| 1236 | 1281 | |
| 1237 | - // Skip brute force counter for this controlled rejection. | |
| 1238 | - add_filter( 'vigilante_skip_failed_login_count', '__return_true' ); | |
| 1239 | - | |
| 1240 | 1282 | // Surface the controlled rejection in the activity log so the admin |
| 1241 | 1283 | // can tell apart "user fails login because they typed wrong password" |
| 1242 | 1284 | // from "user fails login because we are forcing a reset". |
| 1243 | 1285 | if ( $this->activity_log ) { |
| @@ -1309,11 +1351,11 @@ | ||
| 1309 | 1351 | if ( empty( $needs_approval ) ) { |
| 1310 | 1352 | return; |
| 1311 | 1353 | } |
| 1312 | 1354 | |
| 1313 | - // Set pending status | |
| 1314 | - update_user_meta( $user_id, 'vigilante_pending_approval', true ); | |
| 1315 | - update_user_meta( $user_id, 'vigilante_pending_since', time() ); | |
| 1355 | + // Set pending status, on this site only (see site_user_meta_key()). | |
| 1356 | + update_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_approval' ), true ); | |
| 1357 | + update_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_since' ), time() ); | |
| 1316 | 1358 | |
| 1317 | 1359 | // Log |
| 1318 | 1360 | if ( $this->activity_log ) { |
| 1319 | 1361 | $this->activity_log->log( |
| @@ -1346,14 +1388,9 @@ | ||
| 1346 | 1388 | if ( is_wp_error( $user ) ) { |
| 1347 | 1389 | return $user; |
| 1348 | 1390 | } |
| 1349 | 1391 | |
| 1350 | - $is_pending = get_user_meta( $user->ID, 'vigilante_pending_approval', true ); | |
| 1351 | - | |
| 1352 | - if ( $is_pending ) { | |
| 1353 | - // Mark this as a controlled rejection (not a brute force attempt) | |
| 1354 | - add_filter( 'vigilante_skip_failed_login_count', '__return_true' ); | |
| 1355 | - | |
| 1392 | + if ( self::is_pending_anywhere( $user->ID ) ) { | |
| 1356 | 1393 | return new WP_Error( |
| 1357 | 1394 | 'pending_approval', |
| 1358 | 1395 | __( '<strong>Account pending:</strong> Your account is awaiting administrator approval. You will receive an email once approved.', 'vigilante' ) |
| 1359 | 1396 | ); |
| @@ -1402,16 +1439,131 @@ | ||
| 1402 | 1439 | <?php |
| 1403 | 1440 | } |
| 1404 | 1441 | |
| 1405 | 1442 | /** |
| 1443 | + * A user meta key that belongs to one site, even on a network | |
| 1444 | + * | |
| 1445 | + * Registration approval is a per-site setting, but user meta is network | |
| 1446 | + * wide, so a single global key made the pending queue shared: an | |
| 1447 | + * administrator of one site saw, approved and rejected accounts waiting on | |
| 1448 | + * another, and clearing the flag cleared it for the whole network. Reported | |
| 1449 | + * by the wp.org automated review of 2.11.9. | |
| 1450 | + * | |
| 1451 | + * On a network the key carries the blog prefix, the way core does with | |
| 1452 | + * capabilities (wp_2_capabilities), so each site keeps its own queue. On a | |
| 1453 | + * single site the key is returned unchanged, so nothing has to be migrated | |
| 1454 | + * there and the stored data of every existing install keeps working. | |
| 1455 | + * | |
| 1456 | + * @since 2.11.10 | |
| 1457 | + * | |
| 1458 | + * Note the default is null and not 0: wpdb::get_blog_prefix() reads null as | |
| 1459 | + * "the current blog", and 0 as the main site, so passing 0 here gave every | |
| 1460 | + * subsite the key of the main site and kept the queue shared. Caught by | |
| 1461 | + * matriz-red-repaso-21110.sh before this shipped. | |
| 1462 | + * | |
| 1463 | + * @param string $key Base meta key. | |
| 1464 | + * @param int|null $blog_id Blog to build it for. Current blog when null. | |
| 1465 | + * @return string | |
| 1466 | + */ | |
| 1467 | + public static function site_user_meta_key( $key, $blog_id = null ) { | |
| 1468 | + global $wpdb; | |
| 1469 | + | |
| 1470 | + if ( ! is_multisite() ) { | |
| 1471 | + return $key; | |
| 1472 | + } | |
| 1473 | + | |
| 1474 | + return $wpdb->get_blog_prefix( $blog_id ) . $key; | |
| 1475 | + } | |
| 1476 | + | |
| 1477 | + /** | |
| 1478 | + * Whether this account is waiting for approval on ANY site of the network | |
| 1479 | + * | |
| 1480 | + * The queue is per site and stays per site, because approving somebody is a | |
| 1481 | + * decision of the site they signed up to. Blocking them is a different | |
| 1482 | + * question with a different answer, and giving it the same one was a hole: | |
| 1483 | + * the session cookie WordPress issues is valid on every host of the network | |
| 1484 | + * (COOKIE_DOMAIN and COOKIEPATH, wp-includes/ms-default-constants.php:58-59 | |
| 1485 | + * and :84-88), so an account held back on demo1 logged in through the main | |
| 1486 | + * site, where it carried no flag, and walked straight back into demo1 with | |
| 1487 | + * that cookie. Reproduced over HTTP by the second cross review of 2.11.10. | |
| 1488 | + * It is the same reasoning that two_factor_required_for() already applies: | |
| 1489 | + * network-wide cookie, network-wide enforcement. | |
| 1490 | + * | |
| 1491 | + * Read from the account's own meta in one pass rather than by asking site by | |
| 1492 | + * site, so the cost does not grow with the network. The legacy key with no | |
| 1493 | + * prefix is included because the migration that moves it runs on the first | |
| 1494 | + * admin page load and until then a waiting account has to keep being | |
| 1495 | + * blocked; reading both fails closed. | |
| 1496 | + * | |
| 1497 | + * @since 2.11.10 | |
| 1498 | + * | |
| 1499 | + * @param int $user_id User ID. | |
| 1500 | + * @return bool | |
| 1501 | + */ | |
| 1502 | + public static function is_pending_anywhere( $user_id ) { | |
| 1503 | + global $wpdb; | |
| 1504 | + | |
| 1505 | + if ( get_user_meta( $user_id, 'vigilante_pending_approval', true ) ) { | |
| 1506 | + return true; | |
| 1507 | + } | |
| 1508 | + | |
| 1509 | + if ( ! is_multisite() ) { | |
| 1510 | + return false; | |
| 1511 | + } | |
| 1512 | + | |
| 1513 | + $all = get_user_meta( $user_id ); | |
| 1514 | + | |
| 1515 | + if ( ! is_array( $all ) ) { | |
| 1516 | + return false; | |
| 1517 | + } | |
| 1518 | + | |
| 1519 | + $pattern = '/^' . preg_quote( $wpdb->base_prefix, '/' ) . '(\d+_)?vigilante_pending_approval$/'; | |
| 1520 | + | |
| 1521 | + foreach ( $all as $key => $values ) { | |
| 1522 | + if ( ! preg_match( $pattern, $key, $m ) ) { | |
| 1523 | + continue; | |
| 1524 | + } | |
| 1525 | + | |
| 1526 | + /* | |
| 1527 | + * A mark left behind by a site that no longer exists asks nobody for | |
| 1528 | + * anything: deleting a subsite does not touch this plugin's user meta, | |
| 1529 | + * so the account stayed blocked on the whole network with no queue | |
| 1530 | + * anywhere to clear it from, in a plugin whose users have no WP-CLI. | |
| 1531 | + * Found by the third cross review of 2.11.10. get_site() is cached, so | |
| 1532 | + * this costs nothing in the usual case of no leftovers. | |
| 1533 | + */ | |
| 1534 | + if ( ! empty( $m[1] ) && ! get_site( (int) rtrim( $m[1], '_' ) ) ) { | |
| 1535 | + continue; | |
| 1536 | + } | |
| 1537 | + | |
| 1538 | + foreach ( (array) $values as $value ) { | |
| 1539 | + if ( ! empty( $value ) ) { | |
| 1540 | + return true; | |
| 1541 | + } | |
| 1542 | + } | |
| 1543 | + } | |
| 1544 | + | |
| 1545 | + return false; | |
| 1546 | + } | |
| 1547 | + | |
| 1548 | + /** | |
| 1406 | 1549 | * Get pending users |
| 1407 | 1550 | * |
| 1551 | + * The meta key is what scopes this list to the current site, so on a network | |
| 1552 | + * the query deliberately does not add the site's own membership filter on | |
| 1553 | + * top. Core's WP_User_Query turns the default into "{$prefix}capabilities | |
| 1554 | + * EXISTS" (wp-includes/class-wp-user-query.php:598-604), and an account that | |
| 1555 | + * is waiting for approval can perfectly well have no role yet: it then held | |
| 1556 | + * this site's flag, was blocked from logging in, and appeared in no queue at | |
| 1557 | + * all, so nobody could ever approve or reject it. Found by the second cross | |
| 1558 | + * review of 2.11.10. | |
| 1559 | + * | |
| 1408 | 1560 | * @return array Array of pending user objects. |
| 1409 | 1561 | */ |
| 1410 | 1562 | public function get_pending_users() { |
| 1411 | 1563 | // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Limited results in admin context. |
| 1412 | 1564 | $args = array( |
| 1413 | - 'meta_key' => 'vigilante_pending_approval', | |
| 1565 | + 'meta_key' => self::site_user_meta_key( 'vigilante_pending_approval' ), | |
| 1414 | 1566 | 'meta_value' => '1', |
| 1415 | 1567 | 'orderby' => 'registered', |
| 1416 | 1568 | 'order' => 'DESC', |
| 1417 | 1569 | ); |
| @@ -1416,8 +1568,12 @@ | ||
| 1416 | 1568 | 'order' => 'DESC', |
| 1417 | 1569 | ); |
| 1418 | 1570 | // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value |
| 1419 | 1571 | |
| 1572 | + if ( is_multisite() ) { | |
| 1573 | + $args['blog_id'] = 0; | |
| 1574 | + } | |
| 1575 | + | |
| 1420 | 1576 | return get_users( $args ); |
| 1421 | 1577 | } |
| 1422 | 1578 | |
| 1423 | 1579 | /** |
| @@ -1429,9 +1585,11 @@ | ||
| 1429 | 1585 | */ |
| 1430 | 1586 | public function approve_user( $user_id, $approved_by = 0 ) { |
| 1431 | 1587 | // Same reasoning as reject_user(): approving an account that never asked |
| 1432 | 1588 | // for approval is a no-op that reports success and writes misleading meta. |
| 1433 | - if ( ! get_user_meta( $user_id, 'vigilante_pending_approval', true ) ) { | |
| 1589 | + // Only this site's flag counts, so approving never clears the queue of | |
| 1590 | + // another site of the network (see site_user_meta_key()). | |
| 1591 | + if ( ! get_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_approval' ), true ) ) { | |
| 1434 | 1592 | return false; |
| 1435 | 1593 | } |
| 1436 | 1594 | |
| 1437 | 1595 | $user = get_userdata( $user_id ); |
| @@ -1438,12 +1596,15 @@ | ||
| 1438 | 1596 | if ( ! $user ) { |
| 1439 | 1597 | return false; |
| 1440 | 1598 | } |
| 1441 | 1599 | |
| 1442 | - delete_user_meta( $user_id, 'vigilante_pending_approval' ); | |
| 1443 | - delete_user_meta( $user_id, 'vigilante_pending_since' ); | |
| 1444 | - update_user_meta( $user_id, 'vigilante_approved_by', $approved_by ); | |
| 1445 | - update_user_meta( $user_id, 'vigilante_approved_date', time() ); | |
| 1600 | + delete_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_approval' ) ); | |
| 1601 | + delete_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_since' ) ); | |
| 1602 | + // Por sitio como las dos de arriba: quien aprueba y cuando es un hecho de | |
| 1603 | + // la cola de ESTE sitio, y dejarlas globales hacia que una aprobacion | |
| 1604 | + // pisara el registro de otro (cierra B4 de la revision cruzada). | |
| 1605 | + update_user_meta( $user_id, self::site_user_meta_key( 'vigilante_approved_by' ), $approved_by ); | |
| 1606 | + update_user_meta( $user_id, self::site_user_meta_key( 'vigilante_approved_date' ), time() ); | |
| 1446 | 1607 | |
| 1447 | 1608 | // Log |
| 1448 | 1609 | if ( $this->activity_log ) { |
| 1449 | 1610 | $admin = $approved_by ? get_userdata( $approved_by ) : null; |
| @@ -1484,9 +1645,9 @@ | ||
| 1484 | 1645 | // Only an account actually waiting for approval may be rejected. Without |
| 1485 | 1646 | // this the handler deletes any user id it is given, and wp_delete_user() |
| 1486 | 1647 | // with no reassignment takes their posts with them, skipping the dialog |
| 1487 | 1648 | // core always shows. Deleting a member is the Users screen's job. |
| 1488 | - if ( ! get_user_meta( $user_id, 'vigilante_pending_approval', true ) ) { | |
| 1649 | + if ( ! get_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_approval' ), true ) ) { | |
| 1489 | 1650 | return false; |
| 1490 | 1651 | } |
| 1491 | 1652 | |
| 1492 | 1653 | // Log before deletion |
| @@ -1513,8 +1674,21 @@ | ||
| 1513 | 1674 | |
| 1514 | 1675 | // Send rejection email before deleting |
| 1515 | 1676 | $this->send_rejection_email( $user, $reason ); |
| 1516 | 1677 | |
| 1678 | + /* | |
| 1679 | + * The mark goes first, because on a network the account may well survive | |
| 1680 | + * the deletion: wp_delete_user() only calls remove_user_from_blog() there | |
| 1681 | + * (wp-admin/includes/user.php:440-442), which clears the role and nothing | |
| 1682 | + * of this plugin's own meta. Leaving it behind made Reject a loop with no | |
| 1683 | + * way out: the account stayed blocked on every site of the network, the | |
| 1684 | + * row never left the queue (which since 2.11.10 no longer hides accounts | |
| 1685 | + * without a role), and pressing Reject again sent the rejection email once | |
| 1686 | + * more and reported success. Found by the third cross review of 2.11.10. | |
| 1687 | + */ | |
| 1688 | + delete_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_approval' ) ); | |
| 1689 | + delete_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_since' ) ); | |
| 1690 | + | |
| 1517 | 1691 | // Delete user |
| 1518 | 1692 | require_once ABSPATH . 'wp-admin/includes/user.php'; |
| 1519 | 1693 | return wp_delete_user( $user_id ); |
| 1520 | 1694 | } |
| @@ -1896,8 +2070,31 @@ | ||
| 1896 | 2070 | return max( 0, $count ); |
| 1897 | 2071 | } |
| 1898 | 2072 | |
| 1899 | 2073 | /** |
| 2074 | + * Whether the session store this limit would act on belongs to a whole network | |
| 2075 | + * | |
| 2076 | + * WP_Session_Tokens keeps session_tokens in the usermeta table, which is | |
| 2077 | + * network wide, while this limit is configured per site. So on a network a | |
| 2078 | + * site administrator setting a low limit would count, and with close_oldest | |
| 2079 | + * close, the sessions the same user opened on other sites, including an | |
| 2080 | + * administrator session elsewhere; and block_new would refuse a login over | |
| 2081 | + * sessions that have nothing to do with this site. Reported by the wp.org | |
| 2082 | + * automated review of 2.11.9, on the close_oldest half. | |
| 2083 | + * | |
| 2084 | + * Until the network-wide policy of 3.1.0, the limit simply does not apply on | |
| 2085 | + * a network, and the settings screen says so. On a single site nothing | |
| 2086 | + * changes: there the session store and the setting cover the same thing. | |
| 2087 | + * | |
| 2088 | + * @since 2.11.10 | |
| 2089 | + * | |
| 2090 | + * @return bool | |
| 2091 | + */ | |
| 2092 | + public static function session_limit_is_network_wide() { | |
| 2093 | + return is_multisite(); | |
| 2094 | + } | |
| 2095 | + | |
| 2096 | + /** | |
| 1900 | 2097 | * Check session limit before login completes (for block_new behavior) |
| 1901 | 2098 | * |
| 1902 | 2099 | * @param WP_User $user User object. |
| 1903 | 2100 | * @param string $password Password. |
| @@ -1907,8 +2104,12 @@ | ||
| 1907 | 2104 | if ( is_wp_error( $user ) ) { |
| 1908 | 2105 | return $user; |
| 1909 | 2106 | } |
| 1910 | 2107 | |
| 2108 | + if ( self::session_limit_is_network_wide() ) { | |
| 2109 | + return $user; | |
| 2110 | + } | |
| 2111 | + | |
| 1911 | 2112 | $settings = $this->options['session_limits'] ?? array(); |
| 1912 | 2113 | $max_sessions = absint( $settings['max_sessions'] ?? 3 ); |
| 1913 | 2114 | $exclude_admins = ! empty( $settings['exclude_admins'] ); |
| 1914 | 2115 | |
| @@ -1938,11 +2139,8 @@ | ||
| 1938 | 2139 | 'warning' |
| 1939 | 2140 | ); |
| 1940 | 2141 | } |
| 1941 | 2142 | |
| 1942 | - // Mark this as a controlled rejection (not a brute force attempt) | |
| 1943 | - add_filter( 'vigilante_skip_failed_login_count', '__return_true' ); | |
| 1944 | - | |
| 1945 | 2143 | return new WP_Error( |
| 1946 | 2144 | 'session_limit_exceeded', |
| 1947 | 2145 | sprintf( |
| 1948 | 2146 | /* translators: %d: Maximum sessions allowed */ |
| @@ -1961,8 +2159,12 @@ | ||
| 1961 | 2159 | * @param string $user_login Username. |
| 1962 | 2160 | * @param WP_User $user User object. |
| 1963 | 2161 | */ |
| 1964 | 2162 | public function enforce_session_limit( $user_login, $user ) { |
| 2163 | + if ( self::session_limit_is_network_wide() ) { | |
| 2164 | + return; | |
| 2165 | + } | |
| 2166 | + | |
| 1965 | 2167 | $settings = $this->options['session_limits'] ?? array(); |
| 1966 | 2168 | $max_sessions = absint( $settings['max_sessions'] ?? 3 ); |
| 1967 | 2169 | $behavior = $settings['behavior'] ?? 'block_new'; |
| 1968 | 2170 | $exclude_admins = ! empty( $settings['exclude_admins'] ); |
| @@ -1981,27 +2183,69 @@ | ||
| 1981 | 2183 | return; |
| 1982 | 2184 | } |
| 1983 | 2185 | |
| 1984 | 2186 | if ( 'close_oldest' === $behavior ) { |
| 1985 | - // Sort by login time and destroy oldest | |
| 1986 | - uasort( $all_sessions, function( $a, $b ) { | |
| 1987 | - return ( $a['login'] ?? 0 ) - ( $b['login'] ?? 0 ); | |
| 2187 | + /* | |
| 2188 | + * Remove the oldest sessions by editing the session store directly. | |
| 2189 | + * | |
| 2190 | + * WP_Session_Tokens::get_all() returns array_values( get_sessions() ), | |
| 2191 | + * so its keys are 0, 1, 2, not tokens, and destroy() expects a raw | |
| 2192 | + * token, which is not stored anywhere and cannot be recovered for a | |
| 2193 | + * session other than the current one. Until 2.11.9 the loop passed | |
| 2194 | + * those numeric keys to destroy(), which hashed them, matched nothing | |
| 2195 | + * and closed no session while still counting and logging success, so | |
| 2196 | + * the cap did nothing under close_oldest. Reported by the wp.org | |
| 2197 | + * automated review of 2.11.8. | |
| 2198 | + * | |
| 2199 | + * The store keeps the sessions as the user meta 'session_tokens', | |
| 2200 | + * keyed by the verifier hash( 'sha256', token ), which is the value | |
| 2201 | + * is_current_session() already compares against. So the oldest are | |
| 2202 | + * removed from that map, keeping the current session whatever its age. | |
| 2203 | + * On a network the meta is global (one finding of the multisite audit, | |
| 2204 | + * to be reworked in 3.1.0); here the fix is only to make the removal | |
| 2205 | + * actually happen. | |
| 2206 | + */ | |
| 2207 | + $stored = get_user_meta( $user->ID, 'session_tokens', true ); | |
| 2208 | + | |
| 2209 | + if ( ! is_array( $stored ) || empty( $stored ) ) { | |
| 2210 | + return; | |
| 2211 | + } | |
| 2212 | + | |
| 2213 | + $now = time(); | |
| 2214 | + $changed = false; | |
| 2215 | + | |
| 2216 | + // Expired sessions are dead weight and count for nothing; drop them first. | |
| 2217 | + foreach ( $stored as $verifier => $session ) { | |
| 2218 | + if ( isset( $session['expiration'] ) && (int) $session['expiration'] < $now ) { | |
| 2219 | + unset( $stored[ $verifier ] ); | |
| 2220 | + $changed = true; | |
| 2221 | + } | |
| 2222 | + } | |
| 2223 | + | |
| 2224 | + // Oldest first, keeping the current session whatever its login time. | |
| 2225 | + uasort( $stored, function ( $a, $b ) { | |
| 2226 | + return ( $a['login'] ?? 0 ) <=> ( $b['login'] ?? 0 ); | |
| 1988 | 2227 | } ); |
| 1989 | 2228 | |
| 1990 | - $sessions_to_remove = $session_count - $max_sessions; | |
| 1991 | - $removed = 0; | |
| 2229 | + $sessions_to_remove = count( $stored ) - $max_sessions; | |
| 2230 | + $removed = 0; | |
| 1992 | 2231 | |
| 1993 | - foreach ( $all_sessions as $token_hash => $session ) { | |
| 2232 | + foreach ( $stored as $verifier => $session ) { | |
| 1994 | 2233 | if ( $removed >= $sessions_to_remove ) { |
| 1995 | 2234 | break; |
| 1996 | 2235 | } |
| 1997 | - // Don't remove current session | |
| 1998 | - if ( ! $this->is_current_session( $token_hash ) ) { | |
| 1999 | - $sessions->destroy( $token_hash ); | |
| 2000 | - $removed++; | |
| 2236 | + if ( $this->is_current_session( $verifier ) ) { | |
| 2237 | + continue; | |
| 2001 | 2238 | } |
| 2239 | + unset( $stored[ $verifier ] ); | |
| 2240 | + $removed++; | |
| 2241 | + $changed = true; | |
| 2002 | 2242 | } |
| 2003 | 2243 | |
| 2244 | + if ( $changed ) { | |
| 2245 | + update_user_meta( $user->ID, 'session_tokens', $stored ); | |
| 2246 | + } | |
| 2247 | + | |
| 2004 | 2248 | // Log |
| 2005 | 2249 | if ( $this->activity_log && $removed > 0 ) { |
| 2006 | 2250 | $this->activity_log->log( |
| 2007 | 2251 | 'user', |
| @@ -2051,9 +2295,15 @@ | ||
| 2051 | 2295 | |
| 2052 | 2296 | // Honor both affected_roles AND the per-user exclusion list, and |
| 2053 | 2297 | // clear stale flags if the user no longer matches the rules. |
| 2054 | 2298 | if ( ! $this->is_password_expiration_applicable( $user_id ) ) { |
| 2055 | - if ( get_user_meta( $user_id, 'vigilante_must_change_password', true ) ) { | |
| 2299 | + // Only on a single site, for the same reason as in | |
| 2300 | + // force_password_change_redirect(): on a network the flag belongs to | |
| 2301 | + // the account, and this site's policy says nothing about the site | |
| 2302 | + // that set it. The 2.11.8 fix only covered that method, and this | |
| 2303 | + // notice cleared the flag anyway on the next admin page; found by | |
| 2304 | + // the cross review of 2.11.8. | |
| 2305 | + if ( ! is_multisite() && get_user_meta( $user_id, 'vigilante_must_change_password', true ) ) { | |
| 2056 | 2306 | delete_user_meta( $user_id, 'vigilante_must_change_password' ); |
| 2057 | 2307 | } |
| 2058 | 2308 | return; |
| 2059 | 2309 | } |
| @@ -2116,39 +2366,121 @@ | ||
| 2116 | 2366 | } |
| 2117 | 2367 | } |
| 2118 | 2368 | |
| 2119 | 2369 | /** |
| 2120 | - * Force redirect to password change page | |
| 2370 | + * Whether this user must change an expired password before doing anything else | |
| 2371 | + * | |
| 2372 | + * The flag alone is not enough: it is re-checked against the current policy, | |
| 2373 | + * because the admin may have taken the user's role out of affected_roles or | |
| 2374 | + * added the user to the exclusion list after it was set, which would | |
| 2375 | + * otherwise lock them in a redirect loop. A stale flag is cleared on a | |
| 2376 | + * single site; on a network the meta is shared by every site and the policy | |
| 2377 | + * checked is only this site's, so it is left alone and simply not enforced | |
| 2378 | + * here (a network-wide rework is the 3.1.0 multisite item). | |
| 2379 | + * | |
| 2380 | + * @since 2.11.9 | |
| 2381 | + * | |
| 2382 | + * @param int $user_id User ID. | |
| 2383 | + * @return bool | |
| 2121 | 2384 | */ |
| 2385 | + private function must_change_password( $user_id ) { | |
| 2386 | + if ( ! $user_id ) { | |
| 2387 | + return false; | |
| 2388 | + } | |
| 2389 | + | |
| 2390 | + $flagged = (bool) get_user_meta( $user_id, 'vigilante_must_change_password', true ); | |
| 2391 | + | |
| 2392 | + if ( ! $this->is_password_expiration_applicable( $user_id ) ) { | |
| 2393 | + if ( $flagged && ! is_multisite() ) { | |
| 2394 | + delete_user_meta( $user_id, 'vigilante_must_change_password' ); | |
| 2395 | + } | |
| 2396 | + return false; | |
| 2397 | + } | |
| 2398 | + | |
| 2399 | + if ( $flagged ) { | |
| 2400 | + return true; | |
| 2401 | + } | |
| 2402 | + | |
| 2403 | + // The flag is set at interactive login (check_password_expiration on | |
| 2404 | + // wp_login). A session that authenticates only through REST, XML-RPC or | |
| 2405 | + // an application password never fires wp_login, so the flag can be | |
| 2406 | + // absent while the password is in fact expired. Compute it on the fly | |
| 2407 | + // too, so a non-interactive route is not a way around the block. The | |
| 2408 | + // computation self-seeds the change date on first sight and never locks | |
| 2409 | + // out a user who has no record yet (see is_password_expired()). | |
| 2410 | + return $this->is_password_expired( $user_id ); | |
| 2411 | + } | |
| 2412 | + | |
| 2413 | + /** | |
| 2414 | + * Force a user with an expired password to change it, on wp-admin and AJAX | |
| 2415 | + * | |
| 2416 | + * Until 2.11.9 this only redirected wp-admin pages and skipped AJAX, so an | |
| 2417 | + * expired-password session kept working through admin-ajax, and the REST API | |
| 2418 | + * and the front end were not covered at all. The wp.org automated review of | |
| 2419 | + * 2.11.8 flagged it: setting a flag on login is not enforcement if the flag | |
| 2420 | + * is only read by one redirect. It is now enforced on every entry point, | |
| 2421 | + * here for wp-admin and AJAX and in the three methods below for REST, the | |
| 2422 | + * front end and XML-RPC. The only thing an affected user can still do is | |
| 2423 | + * change the password on profile.php or log out. | |
| 2424 | + */ | |
| 2122 | 2425 | public function force_password_change_redirect() { |
| 2123 | - if ( ! is_user_logged_in() ) { | |
| 2426 | + if ( ! is_user_logged_in() || ! $this->must_change_password( get_current_user_id() ) ) { | |
| 2124 | 2427 | return; |
| 2125 | 2428 | } |
| 2126 | 2429 | |
| 2127 | - // Don't redirect on AJAX or profile page | |
| 2430 | + // AJAX: a redirect is useless, so the request is refused. Changing the | |
| 2431 | + // password is a profile.php form POST, not AJAX, so nothing the user | |
| 2432 | + // needs to fix this is blocked. | |
| 2128 | 2433 | if ( wp_doing_ajax() ) { |
| 2129 | - return; | |
| 2434 | + wp_send_json_error( | |
| 2435 | + array( 'message' => __( 'Your password has expired. Change it in your profile before continuing.', 'vigilante' ) ), | |
| 2436 | + 403 | |
| 2437 | + ); | |
| 2130 | 2438 | } |
| 2131 | 2439 | |
| 2440 | + // profile.php is where the change happens; do not redirect it onto itself. | |
| 2132 | 2441 | global $pagenow; |
| 2133 | 2442 | if ( 'profile.php' === $pagenow ) { |
| 2134 | 2443 | return; |
| 2135 | 2444 | } |
| 2136 | 2445 | |
| 2137 | - $user_id = get_current_user_id(); | |
| 2138 | - $must_change = get_user_meta( $user_id, 'vigilante_must_change_password', true ); | |
| 2446 | + wp_safe_redirect( admin_url( 'profile.php#password' ) ); | |
| 2447 | + exit; | |
| 2448 | + } | |
| 2139 | 2449 | |
| 2140 | - if ( ! $must_change ) { | |
| 2141 | - return; | |
| 2450 | + /** | |
| 2451 | + * Refuse REST API requests from a user whose password has expired | |
| 2452 | + * | |
| 2453 | + * @since 2.11.9 | |
| 2454 | + * | |
| 2455 | + * @param WP_Error|null|true $result Result of the earlier authentication checks. | |
| 2456 | + * @return WP_Error|null|true | |
| 2457 | + */ | |
| 2458 | + public function block_expired_password_rest( $result ) { | |
| 2459 | + // Leave any decision another check already made, and do not act on | |
| 2460 | + // logged-out requests to public endpoints. | |
| 2461 | + if ( null !== $result && false !== $result ) { | |
| 2462 | + return $result; | |
| 2142 | 2463 | } |
| 2143 | 2464 | |
| 2144 | - // Re-validate against current settings: the admin may have removed | |
| 2145 | - // this user's role from affected_roles or added the user to the | |
| 2146 | - // excluded list after the flag was set. Without this check the flag | |
| 2147 | - // outlives the configuration change and locks the user in a redirect | |
| 2148 | - // loop into profile.php. | |
| 2149 | - if ( ! $this->is_password_expiration_applicable( $user_id ) ) { | |
| 2150 | - delete_user_meta( $user_id, 'vigilante_must_change_password' ); | |
| 2465 | + if ( is_user_logged_in() && $this->must_change_password( get_current_user_id() ) ) { | |
| 2466 | + return new WP_Error( | |
| 2467 | + 'vigilante_password_expired', | |
| 2468 | + __( 'Your password has expired. Change it in your profile before using the REST API.', 'vigilante' ), | |
| 2469 | + array( 'status' => 403 ) | |
| 2470 | + ); | |
| 2471 | + } | |
| 2472 | + | |
| 2473 | + return $result; | |
| 2474 | + } | |
| 2475 | + | |
| 2476 | + /** | |
| 2477 | + * Send a user with an expired password to the change page from the front end | |
| 2478 | + * | |
| 2479 | + * @since 2.11.9 | |
| 2480 | + */ | |
| 2481 | + public function force_password_change_frontend() { | |
| 2482 | + if ( is_admin() || ! is_user_logged_in() || ! $this->must_change_password( get_current_user_id() ) ) { | |
| 2151 | 2483 | return; |
| 2152 | 2484 | } |
| 2153 | 2485 | |
| 2154 | 2486 | wp_safe_redirect( admin_url( 'profile.php#password' ) ); |
| @@ -2155,8 +2487,40 @@ | ||
| 2155 | 2487 | exit; |
| 2156 | 2488 | } |
| 2157 | 2489 | |
| 2158 | 2490 | /** |
| 2491 | + * Refuse XML-RPC calls from a user whose password has expired | |
| 2492 | + * | |
| 2493 | + * The last of the four non-wp-admin entry points. XML-RPC authenticates on | |
| 2494 | + * every call with the account credentials (a password or an application | |
| 2495 | + * password), so a session that never touches wp-admin could keep acting | |
| 2496 | + * through xmlrpc.php while the password sits expired. Scoped to XML-RPC | |
| 2497 | + * requests so an ordinary login, which the user needs to reach profile.php, | |
| 2498 | + * is never blocked here. Runs late on authenticate, after core and the | |
| 2499 | + * application-password handler have resolved the user. | |
| 2500 | + * | |
| 2501 | + * @since 2.11.9 | |
| 2502 | + * | |
| 2503 | + * @param WP_User|WP_Error|null $user Result of the earlier authentication. | |
| 2504 | + * @return WP_User|WP_Error|null | |
| 2505 | + */ | |
| 2506 | + public function block_expired_password_xmlrpc( $user ) { | |
| 2507 | + if ( ! ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) ) { | |
| 2508 | + return $user; | |
| 2509 | + } | |
| 2510 | + | |
| 2511 | + if ( $user instanceof WP_User && $this->must_change_password( $user->ID ) ) { | |
| 2512 | + return new WP_Error( | |
| 2513 | + 'vigilante_password_expired', | |
| 2514 | + __( 'Your password has expired. Change it in your profile before using XML-RPC.', 'vigilante' ), | |
| 2515 | + array( 'status' => 403 ) | |
| 2516 | + ); | |
| 2517 | + } | |
| 2518 | + | |
| 2519 | + return $user; | |
| 2520 | + } | |
| 2521 | + | |
| 2522 | + /** | |
| 2159 | 2523 | * Whether password expiration rules currently apply to a given user |
| 2160 | 2524 | * |
| 2161 | 2525 | * Used to detect stale flags after the admin changes affected_roles or |
| 2162 | 2526 | * the per-user exclusion list. |
| @@ -2520,8 +2884,21 @@ | ||
| 2520 | 2884 | * |
| 2521 | 2885 | * @param int $user_id User ID. |
| 2522 | 2886 | */ |
| 2523 | 2887 | public function send_verification_email( $user_id ) { |
| 2888 | + /* | |
| 2889 | + * Never send an account that is already verified back to pending. The | |
| 2890 | + * resend link below reaches this, and while the pending value was | |
| 2891 | + * unreadable (see the note on the meta write) that was harmless; with | |
| 2892 | + * the check working, resending for a verified account would lock its | |
| 2893 | + * owner out of their own site. | |
| 2894 | + */ | |
| 2895 | + if ( metadata_exists( 'user', $user_id, 'vigilante_email_verified' ) | |
| 2896 | + && get_user_meta( $user_id, 'vigilante_email_verified', true ) | |
| 2897 | + ) { | |
| 2898 | + return; | |
| 2899 | + } | |
| 2900 | + | |
| 2524 | 2901 | $user = get_userdata( $user_id ); |
| 2525 | 2902 | if ( ! $user ) { |
| 2526 | 2903 | return; |
| 2527 | 2904 | } |
| @@ -2536,10 +2913,22 @@ | ||
| 2536 | 2913 | |
| 2537 | 2914 | // Store token |
| 2538 | 2915 | update_user_meta( $user_id, 'vigilante_verification_token', $token_hash ); |
| 2539 | 2916 | update_user_meta( $user_id, 'vigilante_verification_expires', $expires ); |
| 2540 | - update_user_meta( $user_id, 'vigilante_email_verified', false ); | |
| 2541 | 2917 | |
| 2918 | + /* | |
| 2919 | + * '0' and not false. update_user_meta() stores false as an empty string | |
| 2920 | + * (maybe_serialize() returns it unchanged and wpdb writes it with %s), and | |
| 2921 | + * an empty string is what get_user_meta() also returns when there is no | |
| 2922 | + * row at all. So from the moment this feature existed until 2.11.10 the | |
| 2923 | + * value written to mean "not verified yet" was read back as "this account | |
| 2924 | + * predates the feature, let it in", and the branch that blocks the login | |
| 2925 | + * was unreachable. Found by the file-by-file review of 2.11.10. '0' is | |
| 2926 | + * falsy in PHP and survives the round trip, and the readers below tell an | |
| 2927 | + * absent row from a stored one with metadata_exists(). | |
| 2928 | + */ | |
| 2929 | + update_user_meta( $user_id, 'vigilante_email_verified', '0' ); | |
| 2930 | + | |
| 2542 | 2931 | // Build verification URL |
| 2543 | 2932 | $verify_url = add_query_arg( |
| 2544 | 2933 | array( |
| 2545 | 2934 | 'vigilante_verify' => '1', |
| @@ -2613,16 +3002,21 @@ | ||
| 2613 | 3002 | if ( is_wp_error( $user ) ) { |
| 2614 | 3003 | return $user; |
| 2615 | 3004 | } |
| 2616 | 3005 | |
| 2617 | - // Check if email is verified | |
| 2618 | - $verified = get_user_meta( $user->ID, 'vigilante_email_verified', true ); | |
| 2619 | - | |
| 2620 | - // If no meta exists, user was created before this feature - allow | |
| 2621 | - if ( '' === $verified ) { | |
| 3006 | + /* | |
| 3007 | + * Only a row that does not exist means "created before this feature". | |
| 3008 | + * An existing row holding an empty string is an account that older | |
| 3009 | + * versions marked as pending, and it has to be blocked like any other: | |
| 3010 | + * reading both the same way is what made this check let everyone in | |
| 3011 | + * (see send_verification_email()). | |
| 3012 | + */ | |
| 3013 | + if ( ! metadata_exists( 'user', $user->ID, 'vigilante_email_verified' ) ) { | |
| 2622 | 3014 | return $user; |
| 2623 | 3015 | } |
| 2624 | 3016 | |
| 3017 | + $verified = get_user_meta( $user->ID, 'vigilante_email_verified', true ); | |
| 3018 | + | |
| 2625 | 3019 | if ( ! $verified ) { |
| 2626 | 3020 | $settings = $this->options['email_verification'] ?? array(); |
| 2627 | 3021 | $allow_resend = ! empty( $settings['allow_resend'] ); |
| 2628 | 3022 | |
| @@ -2664,10 +3058,19 @@ | ||
| 2664 | 3058 | |
| 2665 | 3059 | // Verify nonce to prevent CSRF and user-ID probing. |
| 2666 | 3060 | if ( ! isset( $_GET['_vigilante_nonce'] ) || |
| 2667 | 3061 | ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_vigilante_nonce'] ) ), 'vigilante_resend_verification_' . $user_id ) ) { |
| 2668 | - wp_safe_redirect( add_query_arg( 'vigilante_message', 'invalid', wp_login_url() ) ); | |
| 2669 | - exit; | |
| 3062 | + /* | |
| 3063 | + * Nothing is redirected to the login page until the request | |
| 3064 | + * has proved something, and a bad nonce proves nothing. It | |
| 3065 | + * used to answer with a redirect to wp_login_url(), which | |
| 3066 | + * under a custom login URL IS the secret address, so any | |
| 3067 | + * visitor could read it out of the Location header of a | |
| 3068 | + * request carrying garbage. Found by the third cross review | |
| 3069 | + * of 2.11.10. Returning leaves the request to render the page | |
| 3070 | + * it asked for, which tells nobody anything. | |
| 3071 | + */ | |
| 3072 | + return; | |
| 2670 | 3073 | } |
| 2671 | 3074 | |
| 2672 | 3075 | // Rate limiting: allow 1 resend every 5 minutes per user to prevent email spam. |
| 2673 | 3076 | $transient_key = 'vigilante_resend_' . $user_id; |
| @@ -2688,28 +3091,30 @@ | ||
| 2688 | 3091 | $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0; |
| 2689 | 3092 | // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token-based verification below. |
| 2690 | 3093 | $token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : ''; |
| 2691 | 3094 | |
| 3095 | + // Same as the resend above: no proof, no redirect, so the Location | |
| 3096 | + // header cannot be used to read the custom login URL. | |
| 2692 | 3097 | if ( ! $user_id || ! $token ) { |
| 2693 | - wp_safe_redirect( add_query_arg( 'vigilante_message', 'invalid', wp_login_url() ) ); | |
| 2694 | - exit; | |
| 3098 | + return; | |
| 2695 | 3099 | } |
| 2696 | 3100 | |
| 2697 | - $stored_hash = get_user_meta( $user_id, 'vigilante_verification_token', true ); | |
| 2698 | - $expires = get_user_meta( $user_id, 'vigilante_verification_expires', true ); | |
| 3101 | + $stored_hash = (string) get_user_meta( $user_id, 'vigilante_verification_token', true ); | |
| 3102 | + $expires = (int) get_user_meta( $user_id, 'vigilante_verification_expires', true ); | |
| 2699 | 3103 | |
| 2700 | - // Check expiration | |
| 3104 | + // The token first. Checking the expiry before it answered "expired" for | |
| 3105 | + // any account with no verification pending and "invalid" for one waiting, | |
| 3106 | + // so a wrong link revealed which user ids were waiting (2.11.8). Only the | |
| 3107 | + // holder of the right token learns that it expired. | |
| 3108 | + if ( '' === $stored_hash || ! hash_equals( $stored_hash, wp_hash( $token ) ) ) { | |
| 3109 | + return; | |
| 3110 | + } | |
| 3111 | + | |
| 2701 | 3112 | if ( time() > $expires ) { |
| 2702 | 3113 | wp_safe_redirect( add_query_arg( 'vigilante_message', 'expired', wp_login_url() ) ); |
| 2703 | 3114 | exit; |
| 2704 | 3115 | } |
| 2705 | 3116 | |
| 2706 | - // Verify token | |
| 2707 | - if ( ! hash_equals( $stored_hash, wp_hash( $token ) ) ) { | |
| 2708 | - wp_safe_redirect( add_query_arg( 'vigilante_message', 'invalid', wp_login_url() ) ); | |
| 2709 | - exit; | |
| 2710 | - } | |
| 2711 | - | |
| 2712 | 3117 | // Mark as verified |
| 2713 | 3118 | update_user_meta( $user_id, 'vigilante_email_verified', true ); |
| 2714 | 3119 | delete_user_meta( $user_id, 'vigilante_verification_token' ); |
| 2715 | 3120 | delete_user_meta( $user_id, 'vigilante_verification_expires' ); |
| @@ -2730,12 +3135,11 @@ | ||
| 2730 | 3135 | 'info' |
| 2731 | 3136 | ); |
| 2732 | 3137 | } |
| 2733 | 3138 | |
| 2734 | - // Check if user still needs approval | |
| 2735 | - $is_pending = get_user_meta( $user_id, 'vigilante_pending_approval', true ); | |
| 2736 | - | |
| 2737 | - if ( $is_pending ) { | |
| 3139 | + // Anywhere on the network, so the message matches what will actually | |
| 3140 | + // happen at the login: that is what blocks (see is_pending_anywhere()). | |
| 3141 | + if ( self::is_pending_anywhere( $user_id ) ) { | |
| 2738 | 3142 | // User verified but still pending approval |
| 2739 | 3143 | wp_safe_redirect( |
| 2740 | 3144 | add_query_arg( |
| 2741 | 3145 | array( |
| @@ -2796,14 +3200,16 @@ | ||
| 2796 | 3200 | * @param int $user_id User ID. |
| 2797 | 3201 | * @return bool |
| 2798 | 3202 | */ |
| 2799 | 3203 | public function is_email_verified( $user_id ) { |
| 2800 | - $verified = get_user_meta( $user_id, 'vigilante_email_verified', true ); | |
| 2801 | - | |
| 2802 | - // If no meta exists, consider verified (old users) | |
| 2803 | - if ( '' === $verified ) { | |
| 3204 | + // Same reading as block_unverified_user_login(): only an absent row means | |
| 3205 | + // the account predates the feature. A stored empty string is an account | |
| 3206 | + // an older version left pending. | |
| 3207 | + if ( ! metadata_exists( 'user', $user_id, 'vigilante_email_verified' ) ) { | |
| 2804 | 3208 | return true; |
| 2805 | 3209 | } |
| 3210 | + | |
| 3211 | + $verified = get_user_meta( $user_id, 'vigilante_email_verified', true ); | |
| 2806 | 3212 | |
| 2807 | 3213 | return (bool) $verified; |
| 2808 | 3214 | } |
| 2809 | 3215 | |