| @@ -42,57 +42,20 @@ | ||
| 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. | |
| 48 | - * @param bool $enforcement_only Register only what enforces | |
| 49 | - * state already written to an | |
| 50 | - * account. See | |
| 51 | - * init_enforcement_hooks(). | |
| 46 | + * @param Vigilante_Settings $settings Settings instance. | |
| 47 | + * @param Vigilante_Activity_Log $activity_log Activity log instance. | |
| 52 | 48 | */ |
| 53 | - public function __construct( $settings, $activity_log, $enforcement_only = false ) { | |
| 49 | + public function __construct( $settings, $activity_log ) { | |
| 54 | 50 | $this->settings = $settings; |
| 55 | 51 | $this->activity_log = $activity_log; |
| 56 | 52 | $this->options = $settings->get_section( 'user_security' ); |
| 57 | 53 | |
| 58 | - if ( $enforcement_only ) { | |
| 59 | - $this->init_enforcement_hooks(); | |
| 60 | - return; | |
| 61 | - } | |
| 62 | - | |
| 63 | 54 | $this->init_hooks(); |
| 64 | 55 | } |
| 65 | 56 | |
| 66 | 57 | /** |
| 67 | - * The hooks that enforce state already written to an account | |
| 68 | - * | |
| 69 | - * A forced password reset and a registration waiting for approval are not | |
| 70 | - * settings, they are marks on somebody's account, and the action that wrote | |
| 71 | - * them already happened: sessions destroyed, emails sent, the activity log | |
| 72 | - * saying those accounts cannot get in until they reset or are approved. | |
| 73 | - * | |
| 74 | - * Until 2.11.10 both were registered inside the module gate, so turning User | |
| 75 | - * Security off let every one of those accounts back in with their old | |
| 76 | - * password, silently and with the flags still in place saying the opposite. | |
| 77 | - * The forced reset is deliberately not destructive on the password (see | |
| 78 | - * force_password_reset(), which avoids wp_set_password() so the reset link | |
| 79 | - * keeps working), so this filter was the only thing holding the door. | |
| 80 | - * Found by the file-by-file review of 2.11.10. | |
| 81 | - * | |
| 82 | - * These two are therefore registered whether the module is on or off. Both | |
| 83 | - * return immediately when the account carries no mark, so the cost on a site | |
| 84 | - * that never used either feature is one meta read at login. | |
| 85 | - * | |
| 86 | - * @since 2.11.10 | |
| 87 | - */ | |
| 88 | - private function init_enforcement_hooks() { | |
| 89 | - add_filter( 'authenticate', array( $this, 'check_force_reset_on_login' ), 30, 3 ); | |
| 90 | - add_action( 'after_password_reset', array( $this, 'clear_force_reset_meta' ), 10, 1 ); | |
| 91 | - add_filter( 'wp_authenticate_user', array( $this, 'block_pending_user_login' ), 15, 2 ); | |
| 92 | - } | |
| 93 | - | |
| 94 | - /** | |
| 95 | 58 | * Initialize hooks |
| 96 | 59 | */ |
| 97 | 60 | private function init_hooks() { |
| 98 | 61 | // Block insecure usernames |
| @@ -141,10 +104,9 @@ | ||
| 141 | 104 | // Registration approval |
| 142 | 105 | $registration_approval = $this->options['registration_approval'] ?? array(); |
| 143 | 106 | if ( ! empty( $registration_approval['enabled'] ) ) { |
| 144 | 107 | add_action( 'user_register', array( $this, 'set_user_pending_approval' ), 5 ); |
| 145 | - // The blocking half is registered by init_enforcement_hooks(), so an | |
| 146 | - // account already waiting keeps waiting if the feature is turned off. | |
| 108 | + add_filter( 'wp_authenticate_user', array( $this, 'block_pending_user_login' ), 15, 2 ); | |
| 147 | 109 | add_action( 'admin_notices', array( $this, 'show_pending_users_notice' ) ); |
| 148 | 110 | } |
| 149 | 111 | |
| 150 | 112 | // Session limits |
| @@ -169,13 +131,8 @@ | ||
| 169 | 131 | if ( ! empty( $password_expiration['enabled'] ) ) { |
| 170 | 132 | add_action( 'wp_login', array( $this, 'check_password_expiration' ), 10, 2 ); |
| 171 | 133 | add_action( 'admin_notices', array( $this, 'show_password_expiration_notice' ) ); |
| 172 | 134 | add_action( 'admin_init', array( $this, 'force_password_change_redirect' ) ); |
| 173 | - // Enforcement beyond wp-admin: REST and the front end, so an expired | |
| 174 | - // password cannot keep operating outside the redirect (2.11.9). | |
| 175 | - add_filter( 'rest_authentication_errors', array( $this, 'block_expired_password_rest' ), 20 ); | |
| 176 | - add_action( 'template_redirect', array( $this, 'force_password_change_frontend' ) ); | |
| 177 | - add_filter( 'authenticate', array( $this, 'block_expired_password_xmlrpc' ), 30, 1 ); | |
| 178 | 135 | add_action( 'profile_update', array( $this, 'update_password_change_date' ), 10, 2 ); |
| 179 | 136 | add_action( 'user_register', array( $this, 'set_initial_password_date' ) ); |
| 180 | 137 | add_action( 'user_profile_update_errors', array( $this, 'check_password_history' ), 10, 3 ); |
| 181 | 138 | |
| @@ -203,11 +160,11 @@ | ||
| 203 | 160 | add_filter( 'registration_redirect', array( $this, 'custom_registration_redirect' ) ); |
| 204 | 161 | add_action( 'login_message', array( $this, 'show_registration_pending_message' ) ); |
| 205 | 162 | } |
| 206 | 163 | |
| 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(); | |
| 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 ); | |
| 210 | 167 | } |
| 211 | 168 | |
| 212 | 169 | /** |
| 213 | 170 | * Validate username on profile update |
| @@ -1249,28 +1206,23 @@ | ||
| 1249 | 1206 | * @param string $password Password. |
| 1250 | 1207 | * @return WP_User|WP_Error|null |
| 1251 | 1208 | */ |
| 1252 | 1209 | public function check_force_reset_on_login( $user, $username, $password ) { |
| 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 ) ) { | |
| 1210 | + // Resolve the target user. The flag must be evaluated whether the | |
| 1211 | + // credentials matched (WP_User) or not (WP_Error). | |
| 1212 | + if ( $user instanceof WP_User ) { | |
| 1213 | + $login_user = $user; | |
| 1214 | + } else { | |
| 1215 | + $login_user = get_user_by( 'login', $username ); | |
| 1216 | + if ( ! $login_user ) { | |
| 1217 | + $login_user = get_user_by( 'email', $username ); | |
| 1218 | + } | |
| 1219 | + } | |
| 1220 | + | |
| 1221 | + if ( ! $login_user ) { | |
| 1268 | 1222 | return $user; |
| 1269 | 1223 | } |
| 1270 | 1224 | |
| 1271 | - $login_user = $user; | |
| 1272 | - | |
| 1273 | 1225 | // Check if this user has a pending forced reset. |
| 1274 | 1226 | $force_reset = get_user_meta( $login_user->ID, 'vigilante_force_reset_pending', true ); |
| 1275 | 1227 | if ( ! $force_reset ) { |
| 1276 | 1228 | return $user; |
| @@ -1275,11 +1227,17 @@ | ||
| 1275 | 1227 | if ( ! $force_reset ) { |
| 1276 | 1228 | return $user; |
| 1277 | 1229 | } |
| 1278 | 1230 | |
| 1279 | - // Not counted towards the brute force lockout: the rejection is | |
| 1280 | - // recognised by its error code (Vigilante_Login_Security::CONTROLLED_REJECTIONS). | |
| 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 | + } | |
| 1281 | 1236 | |
| 1237 | + // Skip brute force counter for this controlled rejection. | |
| 1238 | + add_filter( 'vigilante_skip_failed_login_count', '__return_true' ); | |
| 1239 | + | |
| 1282 | 1240 | // Surface the controlled rejection in the activity log so the admin |
| 1283 | 1241 | // can tell apart "user fails login because they typed wrong password" |
| 1284 | 1242 | // from "user fails login because we are forcing a reset". |
| 1285 | 1243 | if ( $this->activity_log ) { |
| @@ -1351,11 +1309,11 @@ | ||
| 1351 | 1309 | if ( empty( $needs_approval ) ) { |
| 1352 | 1310 | return; |
| 1353 | 1311 | } |
| 1354 | 1312 | |
| 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() ); | |
| 1313 | + // Set pending status | |
| 1314 | + update_user_meta( $user_id, 'vigilante_pending_approval', true ); | |
| 1315 | + update_user_meta( $user_id, 'vigilante_pending_since', time() ); | |
| 1358 | 1316 | |
| 1359 | 1317 | // Log |
| 1360 | 1318 | if ( $this->activity_log ) { |
| 1361 | 1319 | $this->activity_log->log( |
| @@ -1388,9 +1346,14 @@ | ||
| 1388 | 1346 | if ( is_wp_error( $user ) ) { |
| 1389 | 1347 | return $user; |
| 1390 | 1348 | } |
| 1391 | 1349 | |
| 1392 | - if ( self::is_pending_anywhere( $user->ID ) ) { | |
| 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 | + | |
| 1393 | 1356 | return new WP_Error( |
| 1394 | 1357 | 'pending_approval', |
| 1395 | 1358 | __( '<strong>Account pending:</strong> Your account is awaiting administrator approval. You will receive an email once approved.', 'vigilante' ) |
| 1396 | 1359 | ); |
| @@ -1439,131 +1402,16 @@ | ||
| 1439 | 1402 | <?php |
| 1440 | 1403 | } |
| 1441 | 1404 | |
| 1442 | 1405 | /** |
| 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 | - /** | |
| 1549 | 1406 | * Get pending users |
| 1550 | 1407 | * |
| 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 | - * | |
| 1560 | 1408 | * @return array Array of pending user objects. |
| 1561 | 1409 | */ |
| 1562 | 1410 | public function get_pending_users() { |
| 1563 | 1411 | // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Limited results in admin context. |
| 1564 | 1412 | $args = array( |
| 1565 | - 'meta_key' => self::site_user_meta_key( 'vigilante_pending_approval' ), | |
| 1413 | + 'meta_key' => 'vigilante_pending_approval', | |
| 1566 | 1414 | 'meta_value' => '1', |
| 1567 | 1415 | 'orderby' => 'registered', |
| 1568 | 1416 | 'order' => 'DESC', |
| 1569 | 1417 | ); |
| @@ -1568,12 +1416,8 @@ | ||
| 1568 | 1416 | 'order' => 'DESC', |
| 1569 | 1417 | ); |
| 1570 | 1418 | // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value |
| 1571 | 1419 | |
| 1572 | - if ( is_multisite() ) { | |
| 1573 | - $args['blog_id'] = 0; | |
| 1574 | - } | |
| 1575 | - | |
| 1576 | 1420 | return get_users( $args ); |
| 1577 | 1421 | } |
| 1578 | 1422 | |
| 1579 | 1423 | /** |
| @@ -1585,11 +1429,9 @@ | ||
| 1585 | 1429 | */ |
| 1586 | 1430 | public function approve_user( $user_id, $approved_by = 0 ) { |
| 1587 | 1431 | // Same reasoning as reject_user(): approving an account that never asked |
| 1588 | 1432 | // for approval is a no-op that reports success and writes misleading meta. |
| 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 ) ) { | |
| 1433 | + if ( ! get_user_meta( $user_id, 'vigilante_pending_approval', true ) ) { | |
| 1592 | 1434 | return false; |
| 1593 | 1435 | } |
| 1594 | 1436 | |
| 1595 | 1437 | $user = get_userdata( $user_id ); |
| @@ -1596,15 +1438,12 @@ | ||
| 1596 | 1438 | if ( ! $user ) { |
| 1597 | 1439 | return false; |
| 1598 | 1440 | } |
| 1599 | 1441 | |
| 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() ); | |
| 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() ); | |
| 1607 | 1446 | |
| 1608 | 1447 | // Log |
| 1609 | 1448 | if ( $this->activity_log ) { |
| 1610 | 1449 | $admin = $approved_by ? get_userdata( $approved_by ) : null; |
| @@ -1645,9 +1484,9 @@ | ||
| 1645 | 1484 | // Only an account actually waiting for approval may be rejected. Without |
| 1646 | 1485 | // this the handler deletes any user id it is given, and wp_delete_user() |
| 1647 | 1486 | // with no reassignment takes their posts with them, skipping the dialog |
| 1648 | 1487 | // core always shows. Deleting a member is the Users screen's job. |
| 1649 | - if ( ! get_user_meta( $user_id, self::site_user_meta_key( 'vigilante_pending_approval' ), true ) ) { | |
| 1488 | + if ( ! get_user_meta( $user_id, 'vigilante_pending_approval', true ) ) { | |
| 1650 | 1489 | return false; |
| 1651 | 1490 | } |
| 1652 | 1491 | |
| 1653 | 1492 | // Log before deletion |
| @@ -1674,21 +1513,8 @@ | ||
| 1674 | 1513 | |
| 1675 | 1514 | // Send rejection email before deleting |
| 1676 | 1515 | $this->send_rejection_email( $user, $reason ); |
| 1677 | 1516 | |
| 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 | - | |
| 1691 | 1517 | // Delete user |
| 1692 | 1518 | require_once ABSPATH . 'wp-admin/includes/user.php'; |
| 1693 | 1519 | return wp_delete_user( $user_id ); |
| 1694 | 1520 | } |
| @@ -2070,31 +1896,8 @@ | ||
| 2070 | 1896 | return max( 0, $count ); |
| 2071 | 1897 | } |
| 2072 | 1898 | |
| 2073 | 1899 | /** |
| 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 | - /** | |
| 2097 | 1900 | * Check session limit before login completes (for block_new behavior) |
| 2098 | 1901 | * |
| 2099 | 1902 | * @param WP_User $user User object. |
| 2100 | 1903 | * @param string $password Password. |
| @@ -2104,12 +1907,8 @@ | ||
| 2104 | 1907 | if ( is_wp_error( $user ) ) { |
| 2105 | 1908 | return $user; |
| 2106 | 1909 | } |
| 2107 | 1910 | |
| 2108 | - if ( self::session_limit_is_network_wide() ) { | |
| 2109 | - return $user; | |
| 2110 | - } | |
| 2111 | - | |
| 2112 | 1911 | $settings = $this->options['session_limits'] ?? array(); |
| 2113 | 1912 | $max_sessions = absint( $settings['max_sessions'] ?? 3 ); |
| 2114 | 1913 | $exclude_admins = ! empty( $settings['exclude_admins'] ); |
| 2115 | 1914 | |
| @@ -2139,8 +1938,11 @@ | ||
| 2139 | 1938 | 'warning' |
| 2140 | 1939 | ); |
| 2141 | 1940 | } |
| 2142 | 1941 | |
| 1942 | + // Mark this as a controlled rejection (not a brute force attempt) | |
| 1943 | + add_filter( 'vigilante_skip_failed_login_count', '__return_true' ); | |
| 1944 | + | |
| 2143 | 1945 | return new WP_Error( |
| 2144 | 1946 | 'session_limit_exceeded', |
| 2145 | 1947 | sprintf( |
| 2146 | 1948 | /* translators: %d: Maximum sessions allowed */ |
| @@ -2159,12 +1961,8 @@ | ||
| 2159 | 1961 | * @param string $user_login Username. |
| 2160 | 1962 | * @param WP_User $user User object. |
| 2161 | 1963 | */ |
| 2162 | 1964 | public function enforce_session_limit( $user_login, $user ) { |
| 2163 | - if ( self::session_limit_is_network_wide() ) { | |
| 2164 | - return; | |
| 2165 | - } | |
| 2166 | - | |
| 2167 | 1965 | $settings = $this->options['session_limits'] ?? array(); |
| 2168 | 1966 | $max_sessions = absint( $settings['max_sessions'] ?? 3 ); |
| 2169 | 1967 | $behavior = $settings['behavior'] ?? 'block_new'; |
| 2170 | 1968 | $exclude_admins = ! empty( $settings['exclude_admins'] ); |
| @@ -2183,69 +1981,27 @@ | ||
| 2183 | 1981 | return; |
| 2184 | 1982 | } |
| 2185 | 1983 | |
| 2186 | 1984 | if ( 'close_oldest' === $behavior ) { |
| 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 ); | |
| 1985 | + // Sort by login time and destroy oldest | |
| 1986 | + uasort( $all_sessions, function( $a, $b ) { | |
| 1987 | + return ( $a['login'] ?? 0 ) - ( $b['login'] ?? 0 ); | |
| 2227 | 1988 | } ); |
| 2228 | 1989 | |
| 2229 | - $sessions_to_remove = count( $stored ) - $max_sessions; | |
| 2230 | - $removed = 0; | |
| 1990 | + $sessions_to_remove = $session_count - $max_sessions; | |
| 1991 | + $removed = 0; | |
| 2231 | 1992 | |
| 2232 | - foreach ( $stored as $verifier => $session ) { | |
| 1993 | + foreach ( $all_sessions as $token_hash => $session ) { | |
| 2233 | 1994 | if ( $removed >= $sessions_to_remove ) { |
| 2234 | 1995 | break; |
| 2235 | 1996 | } |
| 2236 | - if ( $this->is_current_session( $verifier ) ) { | |
| 2237 | - continue; | |
| 1997 | + // Don't remove current session | |
| 1998 | + if ( ! $this->is_current_session( $token_hash ) ) { | |
| 1999 | + $sessions->destroy( $token_hash ); | |
| 2000 | + $removed++; | |
| 2238 | 2001 | } |
| 2239 | - unset( $stored[ $verifier ] ); | |
| 2240 | - $removed++; | |
| 2241 | - $changed = true; | |
| 2242 | 2002 | } |
| 2243 | 2003 | |
| 2244 | - if ( $changed ) { | |
| 2245 | - update_user_meta( $user->ID, 'session_tokens', $stored ); | |
| 2246 | - } | |
| 2247 | - | |
| 2248 | 2004 | // Log |
| 2249 | 2005 | if ( $this->activity_log && $removed > 0 ) { |
| 2250 | 2006 | $this->activity_log->log( |
| 2251 | 2007 | 'user', |
| @@ -2366,121 +2122,46 @@ | ||
| 2366 | 2122 | } |
| 2367 | 2123 | } |
| 2368 | 2124 | |
| 2369 | 2125 | /** |
| 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 | |
| 2126 | + * Force redirect to password change page | |
| 2384 | 2127 | */ |
| 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 | - */ | |
| 2425 | 2128 | public function force_password_change_redirect() { |
| 2426 | - if ( ! is_user_logged_in() || ! $this->must_change_password( get_current_user_id() ) ) { | |
| 2129 | + if ( ! is_user_logged_in() ) { | |
| 2427 | 2130 | return; |
| 2428 | 2131 | } |
| 2429 | 2132 | |
| 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. | |
| 2133 | + // Don't redirect on AJAX or profile page | |
| 2433 | 2134 | if ( wp_doing_ajax() ) { |
| 2434 | - wp_send_json_error( | |
| 2435 | - array( 'message' => __( 'Your password has expired. Change it in your profile before continuing.', 'vigilante' ) ), | |
| 2436 | - 403 | |
| 2437 | - ); | |
| 2135 | + return; | |
| 2438 | 2136 | } |
| 2439 | 2137 | |
| 2440 | - // profile.php is where the change happens; do not redirect it onto itself. | |
| 2441 | 2138 | global $pagenow; |
| 2442 | 2139 | if ( 'profile.php' === $pagenow ) { |
| 2443 | 2140 | return; |
| 2444 | 2141 | } |
| 2445 | 2142 | |
| 2446 | - wp_safe_redirect( admin_url( 'profile.php#password' ) ); | |
| 2447 | - exit; | |
| 2448 | - } | |
| 2143 | + $user_id = get_current_user_id(); | |
| 2144 | + $must_change = get_user_meta( $user_id, 'vigilante_must_change_password', true ); | |
| 2449 | 2145 | |
| 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; | |
| 2146 | + if ( ! $must_change ) { | |
| 2147 | + return; | |
| 2463 | 2148 | } |
| 2464 | 2149 | |
| 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() ) ) { | |
| 2150 | + // Re-validate against current settings: the admin may have removed | |
| 2151 | + // this user's role from affected_roles or added the user to the | |
| 2152 | + // excluded list after the flag was set. Without this check the flag | |
| 2153 | + // outlives the configuration change and locks the user in a redirect | |
| 2154 | + // loop into profile.php. | |
| 2155 | + if ( ! $this->is_password_expiration_applicable( $user_id ) ) { | |
| 2156 | + // On a network the flag is a user meta that every site shares, and the | |
| 2157 | + // policy just checked is only this site's: another site may have set | |
| 2158 | + // it, and clearing it here let a user skip that site's forced change | |
| 2159 | + // by visiting any other dashboard. It is only cleared on a single | |
| 2160 | + // site (2.11.8); on a network the user is just not redirected here. | |
| 2161 | + if ( ! is_multisite() ) { | |
| 2162 | + delete_user_meta( $user_id, 'vigilante_must_change_password' ); | |
| 2163 | + } | |
| 2483 | 2164 | return; |
| 2484 | 2165 | } |
| 2485 | 2166 | |
| 2486 | 2167 | wp_safe_redirect( admin_url( 'profile.php#password' ) ); |
| @@ -2487,40 +2168,8 @@ | ||
| 2487 | 2168 | exit; |
| 2488 | 2169 | } |
| 2489 | 2170 | |
| 2490 | 2171 | /** |
| 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 | - /** | |
| 2523 | 2172 | * Whether password expiration rules currently apply to a given user |
| 2524 | 2173 | * |
| 2525 | 2174 | * Used to detect stale flags after the admin changes affected_roles or |
| 2526 | 2175 | * the per-user exclusion list. |
| @@ -2884,21 +2533,8 @@ | ||
| 2884 | 2533 | * |
| 2885 | 2534 | * @param int $user_id User ID. |
| 2886 | 2535 | */ |
| 2887 | 2536 | 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 | - | |
| 2901 | 2537 | $user = get_userdata( $user_id ); |
| 2902 | 2538 | if ( ! $user ) { |
| 2903 | 2539 | return; |
| 2904 | 2540 | } |
| @@ -2913,22 +2549,10 @@ | ||
| 2913 | 2549 | |
| 2914 | 2550 | // Store token |
| 2915 | 2551 | update_user_meta( $user_id, 'vigilante_verification_token', $token_hash ); |
| 2916 | 2552 | update_user_meta( $user_id, 'vigilante_verification_expires', $expires ); |
| 2553 | + update_user_meta( $user_id, 'vigilante_email_verified', false ); | |
| 2917 | 2554 | |
| 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 | - | |
| 2931 | 2555 | // Build verification URL |
| 2932 | 2556 | $verify_url = add_query_arg( |
| 2933 | 2557 | array( |
| 2934 | 2558 | 'vigilante_verify' => '1', |
| @@ -3002,21 +2626,16 @@ | ||
| 3002 | 2626 | if ( is_wp_error( $user ) ) { |
| 3003 | 2627 | return $user; |
| 3004 | 2628 | } |
| 3005 | 2629 | |
| 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' ) ) { | |
| 2630 | + // Check if email is verified | |
| 2631 | + $verified = get_user_meta( $user->ID, 'vigilante_email_verified', true ); | |
| 2632 | + | |
| 2633 | + // If no meta exists, user was created before this feature - allow | |
| 2634 | + if ( '' === $verified ) { | |
| 3014 | 2635 | return $user; |
| 3015 | 2636 | } |
| 3016 | 2637 | |
| 3017 | - $verified = get_user_meta( $user->ID, 'vigilante_email_verified', true ); | |
| 3018 | - | |
| 3019 | 2638 | if ( ! $verified ) { |
| 3020 | 2639 | $settings = $this->options['email_verification'] ?? array(); |
| 3021 | 2640 | $allow_resend = ! empty( $settings['allow_resend'] ); |
| 3022 | 2641 | |
| @@ -3058,19 +2677,10 @@ | ||
| 3058 | 2677 | |
| 3059 | 2678 | // Verify nonce to prevent CSRF and user-ID probing. |
| 3060 | 2679 | if ( ! isset( $_GET['_vigilante_nonce'] ) || |
| 3061 | 2680 | ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_vigilante_nonce'] ) ), 'vigilante_resend_verification_' . $user_id ) ) { |
| 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; | |
| 2681 | + wp_safe_redirect( add_query_arg( 'vigilante_message', 'invalid', wp_login_url() ) ); | |
| 2682 | + exit; | |
| 3073 | 2683 | } |
| 3074 | 2684 | |
| 3075 | 2685 | // Rate limiting: allow 1 resend every 5 minutes per user to prevent email spam. |
| 3076 | 2686 | $transient_key = 'vigilante_resend_' . $user_id; |
| @@ -3091,12 +2701,11 @@ | ||
| 3091 | 2701 | $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0; |
| 3092 | 2702 | // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token-based verification below. |
| 3093 | 2703 | $token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : ''; |
| 3094 | 2704 | |
| 3095 | - // Same as the resend above: no proof, no redirect, so the Location | |
| 3096 | - // header cannot be used to read the custom login URL. | |
| 3097 | 2705 | if ( ! $user_id || ! $token ) { |
| 3098 | - return; | |
| 2706 | + wp_safe_redirect( add_query_arg( 'vigilante_message', 'invalid', wp_login_url() ) ); | |
| 2707 | + exit; | |
| 3099 | 2708 | } |
| 3100 | 2709 | |
| 3101 | 2710 | $stored_hash = (string) get_user_meta( $user_id, 'vigilante_verification_token', true ); |
| 3102 | 2711 | $expires = (int) get_user_meta( $user_id, 'vigilante_verification_expires', true ); |
| @@ -3105,9 +2714,10 @@ | ||
| 3105 | 2714 | // any account with no verification pending and "invalid" for one waiting, |
| 3106 | 2715 | // so a wrong link revealed which user ids were waiting (2.11.8). Only the |
| 3107 | 2716 | // holder of the right token learns that it expired. |
| 3108 | 2717 | if ( '' === $stored_hash || ! hash_equals( $stored_hash, wp_hash( $token ) ) ) { |
| 3109 | - return; | |
| 2718 | + wp_safe_redirect( add_query_arg( 'vigilante_message', 'invalid', wp_login_url() ) ); | |
| 2719 | + exit; | |
| 3110 | 2720 | } |
| 3111 | 2721 | |
| 3112 | 2722 | if ( time() > $expires ) { |
| 3113 | 2723 | wp_safe_redirect( add_query_arg( 'vigilante_message', 'expired', wp_login_url() ) ); |
| @@ -3135,11 +2745,12 @@ | ||
| 3135 | 2745 | 'info' |
| 3136 | 2746 | ); |
| 3137 | 2747 | } |
| 3138 | 2748 | |
| 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 ) ) { | |
| 2749 | + // Check if user still needs approval | |
| 2750 | + $is_pending = get_user_meta( $user_id, 'vigilante_pending_approval', true ); | |
| 2751 | + | |
| 2752 | + if ( $is_pending ) { | |
| 3142 | 2753 | // User verified but still pending approval |
| 3143 | 2754 | wp_safe_redirect( |
| 3144 | 2755 | add_query_arg( |
| 3145 | 2756 | array( |
| @@ -3200,16 +2811,14 @@ | ||
| 3200 | 2811 | * @param int $user_id User ID. |
| 3201 | 2812 | * @return bool |
| 3202 | 2813 | */ |
| 3203 | 2814 | public function is_email_verified( $user_id ) { |
| 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' ) ) { | |
| 2815 | + $verified = get_user_meta( $user_id, 'vigilante_email_verified', true ); | |
| 2816 | + | |
| 2817 | + // If no meta exists, consider verified (old users) | |
| 2818 | + if ( '' === $verified ) { | |
| 3208 | 2819 | return true; |
| 3209 | 2820 | } |
| 3210 | - | |
| 3211 | - $verified = get_user_meta( $user_id, 'vigilante_email_verified', true ); | |
| 3212 | 2821 | |
| 3213 | 2822 | return (bool) $verified; |
| 3214 | 2823 | } |
| 3215 | 2824 | |