PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.8
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.8
3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
← All changes | includes/class-user-security.php +98 -489 2.11.122.11.8 View file →
@@ -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