diff --git a/security.php b/security.php
index 0ec2896a06..d5179cfca7 100644
--- a/security.php
+++ b/security.php
@@ -16,6 +16,13 @@
require_once __DIR__ . '/security/login-error.php';
require_once __DIR__ . '/security/password.php';
+if ( defined( 'VIP_SECURITY_INACTIVE_USERS_ACTION' ) ) {
+ require_once __DIR__ . '/security/class-user-last-seen.php';
+
+ $last_seen = new \Automattic\VIP\Security\User_Last_Seen();
+ $last_seen->init();
+}
+
use Automattic\VIP\Utils\Context;
define( 'CACHE_GROUP_LOGIN_LIMIT', 'vip_login_limit' );
diff --git a/security/class-user-last-seen.php b/security/class-user-last-seen.php
new file mode 100644
index 0000000000..81338df8d1
--- /dev/null
+++ b/security/class-user-last-seen.php
@@ -0,0 +1,420 @@
+release_date = get_option( self::LAST_SEEN_RELEASE_DATE_TIMESTAMP_OPTION_KEY );
+
+ // Use a global cache group since users are shared among network sites.
+ wp_cache_add_global_groups( array( self::LAST_SEEN_CACHE_GROUP ) );
+
+ add_filter( 'determine_current_user', array( $this, 'record_activity' ), 30, 1 );
+
+ add_action( 'admin_init', array( $this, 'register_release_date' ) );
+ add_action( 'set_user_role', array( $this, 'user_promoted' ) );
+ add_action( 'vip_support_user_added', function ( $user_id ) {
+ $ignore_inactivity_check_until = strtotime( '+2 hours' );
+
+ $this->ignore_inactivity_check_for_user( $user_id, $ignore_inactivity_check_until );
+ } );
+
+ if ( in_array( constant( 'VIP_SECURITY_INACTIVE_USERS_ACTION' ), array( 'REPORT', 'BLOCK' ) ) ) {
+ add_filter( 'wpmu_users_columns', array( $this, 'add_last_seen_column_head' ) );
+ add_filter( 'manage_users_columns', array( $this, 'add_last_seen_column_head' ) );
+ add_filter( 'manage_users_custom_column', array( $this, 'add_last_seen_column_date' ), 10, 3 );
+
+ add_filter( 'manage_users_sortable_columns', array( $this, 'add_last_seen_sortable_column' ) );
+ add_filter( 'manage_users-network_sortable_columns', array( $this, 'add_last_seen_sortable_column' ) );
+ add_filter( 'users_list_table_query_args', array( $this, 'last_seen_order_by_query_args' ) );
+ }
+
+ if ( $this->is_block_action_enabled() ) {
+ add_filter( 'authenticate', array( $this, 'authenticate' ), 20, 1 );
+ add_filter( 'wp_is_application_passwords_available_for_user', array( $this, 'application_password_authentication' ), PHP_INT_MAX, 2 );
+ add_filter( 'rest_authentication_errors', array( $this, 'rest_authentication_errors' ), PHP_INT_MAX, 1 );
+
+ add_filter( 'views_users', array( $this, 'add_blocked_users_filter' ) );
+ add_filter( 'views_users-network', array( $this, 'add_blocked_users_filter' ) );
+ add_filter( 'users_list_table_query_args', array( $this, 'last_seen_blocked_users_filter_query_args' ) );
+
+ add_action( 'admin_init', array( $this, 'last_seen_unblock_action' ) );
+ }
+ }
+
+ public function record_activity( $user_id ) {
+ if ( ! $user_id ) {
+ return $user_id;
+ }
+
+ $user = get_userdata( $user_id );
+ if ( ! $user ) {
+ return $user_id;
+ }
+
+ if ( $this->is_considered_inactive( $user_id ) ) {
+ // User needs to be unblocked first
+ return $user_id;
+ }
+
+ if ( wp_cache_get( $user_id, self::LAST_SEEN_CACHE_GROUP ) ) {
+ // Last seen meta was checked recently
+ return $user_id;
+ }
+
+ // phpcs:ignore WordPressVIPMinimum.Performance.LowExpiryCacheTime.CacheTimeUndetermined
+ if ( wp_cache_add( $user_id, true, self::LAST_SEEN_CACHE_GROUP, self::LAST_SEEN_UPDATE_USER_META_CACHE_TTL ) ) {
+ update_user_meta( $user_id, self::LAST_SEEN_META_KEY, time() );
+ }
+
+ return $user_id;
+ }
+
+ public function authenticate( $user ) {
+ if ( is_wp_error( $user ) ) {
+ return $user;
+ }
+
+ if ( $user->ID && $this->is_considered_inactive( $user->ID ) ) {
+ if ( Context::is_xmlrpc_api() ) {
+ add_filter('xmlrpc_login_error', function () {
+ return new \IXR_Error( 403, __( 'Your account has been flagged as inactive. Please contact your site administrator.', 'wpvip' ) );
+ });
+ }
+
+ return new \WP_Error( 'inactive_account', __( 'Error: Your account has been flagged as inactive. Please contact your site administrator.', 'wpvip' ) );
+ }
+
+ return $user;
+ }
+
+ public function rest_authentication_errors( $status ) {
+ if ( is_wp_error( $this->application_password_authentication_error ) ) {
+ return $this->application_password_authentication_error;
+ }
+
+ return $status;
+ }
+
+ /**
+ * @param bool $available True if application password is available, false otherwise.
+ * @param \WP_User $user The user to check.
+ * @return bool
+ */
+ public function application_password_authentication( $available, $user ) {
+ if ( ! $available || ( $user && ! $user->exists() ) ) {
+ return false;
+ }
+
+ if ( $this->is_considered_inactive( $user->ID ) ) {
+ $this->application_password_authentication_error = new \WP_Error( 'inactive_account', __( 'Your account has been flagged as inactive. Please contact your site administrator.', 'wpvip' ), array( 'status' => 403 ) );
+
+ return false;
+ }
+
+ return $available;
+ }
+
+ public function add_last_seen_column_head( $columns ) {
+ $columns['last_seen'] = __( 'Last seen', 'wpvip' );
+ return $columns;
+ }
+
+ public function add_last_seen_sortable_column( $columns ) {
+ $columns['last_seen'] = 'last_seen';
+
+ return $columns;
+ }
+
+ public function last_seen_order_by_query_args( $vars ) {
+ if ( isset( $vars['orderby'] ) && 'last_seen' === $vars['orderby'] ) {
+ $vars['meta_key'] = self::LAST_SEEN_META_KEY;
+ $vars['orderby'] = 'meta_value_num';
+ }
+
+ return $vars;
+ }
+
+ public function last_seen_blocked_users_filter_query_args( $vars ) {
+ if ( isset( $_GET['last_seen_filter'] ) && 'blocked' === $_GET['last_seen_filter'] && isset( $_GET['last_seen_filter_nonce'] ) && wp_verify_nonce( sanitize_text_field( $_GET['last_seen_filter_nonce'] ), 'last_seen_filter' ) ) {
+ $vars['meta_key'] = self::LAST_SEEN_META_KEY;
+ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+ $vars['meta_value'] = $this->get_inactivity_timestamp();
+ $vars['meta_type'] = 'NUMERIC';
+ $vars['meta_compare'] = '<';
+ }
+
+ return $vars;
+ }
+
+ public function add_last_seen_column_date( $default, $column_name, $user_id ) {
+ if ( 'last_seen' !== $column_name ) {
+ return $default;
+ }
+
+ $last_seen_timestamp = get_user_meta( $user_id, self::LAST_SEEN_META_KEY, true );
+
+ $date = __( 'Indeterminate', 'wpvip' );
+ if ( $last_seen_timestamp ) {
+ $date = sprintf(
+ /* translators: 1: Comment date, 2: Comment time. */
+ __( '%1$s at %2$s' ),
+ date_i18n( get_option( 'date_format' ), $last_seen_timestamp ),
+ date_i18n( get_option( 'time_format' ), $last_seen_timestamp )
+ );
+ }
+
+ if ( ! $this->is_block_action_enabled() || ! $this->is_considered_inactive( $user_id ) ) {
+ return sprintf( '%s', esc_html( $date ) );
+ }
+
+ $unblock_link = '';
+ if ( current_user_can( 'edit_user', $user_id ) ) {
+ $url = add_query_arg( array(
+ 'action' => 'reset_last_seen',
+ 'user_id' => $user_id,
+ 'reset_last_seen_nonce' => wp_create_nonce( 'reset_last_seen_action' ),
+ ) );
+
+ $unblock_link = "
';
+ }
+ return sprintf( '%s' . $unblock_link, esc_html( $date ) );
+ }
+
+ public function add_blocked_users_filter( $views ) {
+ $blog_id = is_network_admin() ? null : get_current_blog_id();
+
+ $users_query = new \WP_User_Query(
+ array(
+ 'blog_id' => $blog_id,
+ 'fields' => 'ID',
+ 'meta_key' => self::LAST_SEEN_META_KEY,
+ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+ 'meta_value' => $this->get_inactivity_timestamp(),
+ 'meta_type' => 'NUMERIC',
+ 'meta_compare' => '<',
+ 'count_total' => false,
+ 'number' => 1, // To minimize the query time, we only need to know if there are any blocked users to show the link
+ ),
+ );
+
+ $views['blocked_users'] = __( 'Blocked Users', 'wpvip' );
+
+ if ( ! $users_query->get_results() ) {
+ return $views;
+ }
+
+ $url = add_query_arg( array(
+ 'last_seen_filter' => 'blocked',
+ 'last_seen_filter_nonce' => wp_create_nonce( 'last_seen_filter' ),
+ ) );
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $class = isset( $_GET['last_seen_filter'] ) ? 'current' : '';
+
+ $view = '' . esc_html( $views['blocked_users'] ) . '';
+
+ $views['blocked_users'] = $view;
+
+ return $views;
+ }
+
+ public function last_seen_unblock_action() {
+ $admin_notices_hook_name = is_network_admin() ? 'network_admin_notices' : 'admin_notices';
+
+ if ( isset( $_GET['reset_last_seen_success'] ) && '1' === $_GET['reset_last_seen_success'] ) {
+ add_action( $admin_notices_hook_name, function () {
+ $class = 'notice notice-success is-dismissible';
+ $error = __( 'User unblocked.', 'wpvip' );
+
+ printf( '', esc_attr( $class ), esc_html( $error ) );
+ } );
+ }
+
+ if ( ! isset( $_GET['user_id'], $_GET['action'] ) || 'reset_last_seen' !== $_GET['action'] ) {
+ return;
+ }
+
+ $user_id = absint( $_GET['user_id'] );
+
+ $error = null;
+ if ( ! isset( $_GET['reset_last_seen_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( $_GET['reset_last_seen_nonce'] ), 'reset_last_seen_action' ) ) {
+ $error = __( 'Unable to verify your request', 'wpvip' );
+ }
+
+ if ( ! get_userdata( $user_id ) ) {
+ $error = __( 'User not found.', 'wpvip' );
+ }
+
+ if ( ! current_user_can( 'edit_user', $user_id ) ) {
+ $error = __( 'You do not have permission to unblock this user.', 'wpvip' );
+ }
+
+ $ignore_inactivity_check_until = strtotime( '+2 days' );
+ if ( ! $error && ! $this->ignore_inactivity_check_for_user( $user_id, $ignore_inactivity_check_until ) ) {
+ $error = __( 'Unable to unblock user.', 'wpvip' );
+ }
+
+ if ( $error ) {
+ add_action( $admin_notices_hook_name, function () use ( $error ) {
+ $class = 'notice notice-error is-dismissible';
+
+ printf( '', esc_attr( $class ), esc_html( $error ) );
+ } );
+ return;
+ }
+
+ $url = remove_query_arg( array(
+ 'action',
+ 'user_id',
+ 'reset_last_seen_nonce',
+ ) );
+
+ $url = add_query_arg( array(
+ 'reset_last_seen_success' => 1,
+ ), $url );
+
+ wp_safe_redirect( $url );
+ exit();
+ }
+
+ public function ignore_inactivity_check_for_user( $user_id, $until_timestamp = null ) {
+ if ( ! $until_timestamp ) {
+ $until_timestamp = strtotime( '+2 days' );
+ }
+
+ return update_user_meta( $user_id, self::LAST_SEEN_IGNORE_INACTIVITY_CHECK_UNTIL_META_KEY, $until_timestamp );
+ }
+
+ public function user_promoted( $user_id ) {
+ $user = get_userdata( $user_id );
+ if ( ! $user ) {
+ throw new \Exception( 'User not found' );
+ }
+
+ if ( ! $this->user_with_elevated_capabilities( $user ) ) {
+ return;
+ }
+
+ $this->ignore_inactivity_check_for_user( $user_id );
+ }
+
+ public function register_release_date() {
+ if ( ! wp_doing_ajax() && ! get_option( self::LAST_SEEN_RELEASE_DATE_TIMESTAMP_OPTION_KEY ) ) {
+ // Right after the first admin_init, set the release date timestamp
+ // to be used as a fallback for users that never logged in before.
+ add_option( self::LAST_SEEN_RELEASE_DATE_TIMESTAMP_OPTION_KEY, time(), '', 'no' );
+ }
+ }
+
+ public function is_considered_inactive( $user_id ) {
+ if ( ! $this->should_check_user_last_seen( $user_id ) ) {
+ return false;
+ }
+
+ $ignore_inactivity_check_until = get_user_meta( $user_id, self::LAST_SEEN_IGNORE_INACTIVITY_CHECK_UNTIL_META_KEY, true );
+ if ( $ignore_inactivity_check_until && $ignore_inactivity_check_until > time() ) {
+ return false;
+ }
+
+ $last_seen_timestamp = get_user_meta( $user_id, self::LAST_SEEN_META_KEY, true );
+ if ( $last_seen_timestamp ) {
+ return $last_seen_timestamp < $this->get_inactivity_timestamp();
+ }
+
+ $release_date_timestamp = get_option( self::LAST_SEEN_RELEASE_DATE_TIMESTAMP_OPTION_KEY );
+ if ( $release_date_timestamp ) {
+ return $release_date_timestamp < $this->get_inactivity_timestamp();
+ }
+
+ // Release date is not defined yet, so we can't consider the user inactive.
+ return false;
+ }
+
+ private function get_inactivity_timestamp() {
+ $days = defined( 'VIP_SECURITY_CONSIDER_USERS_INACTIVE_AFTER_DAYS' ) ? absint( constant( 'VIP_SECURITY_CONSIDER_USERS_INACTIVE_AFTER_DAYS' ) ) : 90;
+
+ return strtotime( sprintf( '-%d days', $days ) ) + self::LAST_SEEN_UPDATE_USER_META_CACHE_TTL;
+ }
+
+ private function is_block_action_enabled() {
+ return defined( 'VIP_SECURITY_INACTIVE_USERS_ACTION' ) && constant( 'VIP_SECURITY_INACTIVE_USERS_ACTION' ) === 'BLOCK';
+ }
+
+ private function should_check_user_last_seen( $user_id ) {
+ /**
+ * Filters the users that should be skipped when checking/recording the last seen.
+ *
+ * @param array $skip_users The list of user IDs to skip.
+ */
+ $skip_users = apply_filters( 'vip_security_last_seen_skip_users', array() );
+ if ( in_array( $user_id, $skip_users ) ) {
+ return false;
+ }
+
+ $user = get_userdata( $user_id );
+ if ( ! $user ) {
+ throw new \Exception( sprintf( 'User #%d found', esc_html( $user_id ) ) );
+ }
+
+ if ( $user->user_registered && strtotime( $user->user_registered ) > $this->get_inactivity_timestamp() ) {
+ return false;
+ }
+
+ return $this->user_with_elevated_capabilities( $user );
+ }
+
+ private function user_with_elevated_capabilities( $user ) {
+ /**
+ * Filters the last seen elevated capabilities that are used to determine if the last seen should be checked.
+ *
+ * @param array $elevated_capabilities The elevated capabilities.
+ */
+ $elevated_capabilities = apply_filters( 'vip_security_last_seen_elevated_capabilities', [
+ 'edit_posts',
+ 'delete_posts',
+ 'publish_posts',
+ 'edit_pages',
+ 'delete_pages',
+ 'publish_pages',
+ 'edit_others_posts',
+ 'edit_others_pages',
+ 'manage_options',
+ 'edit_users',
+ 'promote_users',
+ 'activate_plugins',
+ 'manage_network',
+ ] );
+
+ // Prevent infinite loops inside user_can() due to other security logic.
+ if ( is_automattician( $user->ID ) ) {
+ return true;
+ }
+
+ foreach ( $elevated_capabilities as $elevated_capability ) {
+ if ( user_can( $user, $elevated_capability ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/tests/security/test-class-user-last-seen.php b/tests/security/test-class-user-last-seen.php
new file mode 100644
index 0000000000..3a084eb326
--- /dev/null
+++ b/tests/security/test-class-user-last-seen.php
@@ -0,0 +1,383 @@
+init();
+
+ $this->assertFalse( has_filter( 'rest_authentication_errors' ) );
+ $this->assertFalse( has_filter( 'authenticate' ) );
+ }
+
+ public function test__should_not_load_actions_and_filters_when_env_vars_is_set_to_no_action() {
+ Constant_Mocker::define( 'VIP_SECURITY_INACTIVE_USERS_ACTION', 'NO_ACTION' );
+
+ remove_all_filters( 'rest_authentication_errors' );
+ remove_all_filters( 'authenticate' );
+
+ $last_seen = new User_Last_Seen();
+ $last_seen->init();
+
+ $this->assertFalse( has_filter( 'rest_authentication_errors' ) );
+ $this->assertFalse( has_filter( 'authenticate' ) );
+ }
+
+ public function test__is_considered_inactive__should_consider_user_registered() {
+ Constant_Mocker::define( 'VIP_SECURITY_CONSIDER_USERS_INACTIVE_AFTER_DAYS', 30 );
+ update_option( User_Last_Seen::LAST_SEEN_RELEASE_DATE_TIMESTAMP_OPTION_KEY, strtotime( '-100 days' ) );
+
+ // Recent registered user
+ $user1 = $this->factory()->user->create( array(
+ 'role' => 'administrator',
+ 'user_registered' => gmdate( 'Y-m-d' ),
+ ) );
+
+ // Inactive user (last seen 20 days ago)
+ $user2 = $this->factory()->user->create( array(
+ 'role' => 'administrator',
+ 'user_registered' => '2020-01-01',
+ ) );
+ add_user_meta( $user2, User_Last_Seen::LAST_SEEN_META_KEY, strtotime( '-31 days' ) );
+
+ // Active user (last seen 2 days ago)
+ $user3 = $this->factory()->user->create( array(
+ 'role' => 'administrator',
+ 'user_registered' => '2020-01-01',
+ ) );
+ add_user_meta( $user3, User_Last_Seen::LAST_SEEN_META_KEY, strtotime( '-2 days' ) );
+
+ // Old user without meta
+ $user4 = $this->factory()->user->create( array(
+ 'role' => 'administrator',
+ 'user_registered' => gmdate( 'Y-m-d', strtotime( '-40 days' ) ),
+ ) );
+
+ $last_seen = new User_Last_Seen();
+ $last_seen->init();
+
+ $this->assertFalse( $last_seen->is_considered_inactive( $user1 ) );
+ $this->assertTrue( $last_seen->is_considered_inactive( $user2 ) );
+ $this->assertFalse( $last_seen->is_considered_inactive( $user3 ) );
+ $this->assertTrue( $last_seen->is_considered_inactive( $user4 ) );
+ }
+
+ public function test__is_considered_inactive__add_extra_time_when_user_is_promoted() {
+ Constant_Mocker::define( 'VIP_SECURITY_CONSIDER_USERS_INACTIVE_AFTER_DAYS', 30 );
+ update_option( User_Last_Seen::LAST_SEEN_RELEASE_DATE_TIMESTAMP_OPTION_KEY, strtotime( '-100 days' ) );
+
+ $user_id = $this->factory()->user->create( array(
+ 'role' => 'subscriber',
+ 'user_registered' => '2020-01-01',
+ ) );
+ add_user_meta( $user_id, User_Last_Seen::LAST_SEEN_META_KEY, strtotime( '-31 days' ) );
+
+ $user = get_user_by( 'ID', $user_id );
+ $user->set_role( 'administrator' );
+
+ $last_seen = new User_Last_Seen();
+ $last_seen->init();
+
+ $this->assertTrue( $last_seen->is_considered_inactive( $user_id ) );
+ }
+
+ public function test__is_considered_inactive__should_consider_user_meta() {
+ Constant_Mocker::define( 'VIP_SECURITY_CONSIDER_USERS_INACTIVE_AFTER_DAYS', 30 );
+ update_option( User_Last_Seen::LAST_SEEN_RELEASE_DATE_TIMESTAMP_OPTION_KEY, strtotime( '-100 days' ) );
+
+ $user_inactive_id = $this->factory()->user->create( array(
+ 'role' => 'administrator',
+ 'user_registered' => '2020-01-01',
+ ) );
+ add_user_meta( $user_inactive_id, User_Last_Seen::LAST_SEEN_META_KEY, strtotime( '-31 days' ) );
+
+ $user_active_id = $this->factory()->user->create( array(
+ 'role' => 'administrator',
+ 'user_registered' => '2020-01-01',
+ ) );
+ add_user_meta( $user_active_id, User_Last_Seen::LAST_SEEN_META_KEY, strtotime( '-29 days' ) );
+
+ $last_seen = new User_Last_Seen();
+ $last_seen->init();
+
+ $this->assertTrue( $last_seen->is_considered_inactive( $user_inactive_id ) );
+ $this->assertFalse( $last_seen->is_considered_inactive( $user_active_id ) );
+ }
+
+ public function test__is_considered_inactive__should_return_false_if_user_meta_and_option_are_not_present() {
+ Constant_Mocker::define( 'VIP_SECURITY_CONSIDER_USERS_INACTIVE_AFTER_DAYS', 30 );
+ update_option( User_Last_Seen::LAST_SEEN_RELEASE_DATE_TIMESTAMP_OPTION_KEY, strtotime( '-100 days' ) );
+
+ delete_option( User_Last_Seen::LAST_SEEN_RELEASE_DATE_TIMESTAMP_OPTION_KEY );
+
+ $user_without_meta = $this->factory()->user->create( array( 'role' => 'administrator' ) );
+
+ $last_seen = new \Automattic\VIP\Security\User_Last_Seen();
+ $last_seen->init();
+
+ $this->assertFalse( $last_seen->is_considered_inactive( $user_without_meta ) );
+ }
+
+ public function test__is_considered_inactive__should_use_release_date_option_when_user_meta_is_not_defined() {
+ Constant_Mocker::define( 'VIP_SECURITY_CONSIDER_USERS_INACTIVE_AFTER_DAYS', 15 );
+
+ add_option( User_Last_Seen::LAST_SEEN_RELEASE_DATE_TIMESTAMP_OPTION_KEY, strtotime( '-16 days' ) );
+
+ $user_without_meta = $this->factory()->user->create( array(
+ 'role' => 'administrator',
+ 'user_registered' => '2020-01-01',
+ ) );
+
+ $last_seen = new \Automattic\VIP\Security\User_Last_Seen();
+ $last_seen->init();
+
+ $this->assertTrue( $last_seen->is_considered_inactive( $user_without_meta ) );
+
+ update_option( User_Last_Seen::LAST_SEEN_RELEASE_DATE_TIMESTAMP_OPTION_KEY, strtotime( '-10 days' ) );
+
+ $this->assertFalse( $last_seen->is_considered_inactive( $user_without_meta ) );
+ }
+
+ public function test__authenticate_should_not_return_error_when_user_is_active() {
+ Constant_Mocker::define( 'VIP_SECURITY_INACTIVE_USERS_ACTION', 'BLOCK' );
+ Constant_Mocker::define( 'VIP_SECURITY_CONSIDER_USERS_INACTIVE_AFTER_DAYS', 15 );
+ update_option( User_Last_Seen::LAST_SEEN_RELEASE_DATE_TIMESTAMP_OPTION_KEY, strtotime( '-100 days' ) );
+
+ remove_all_filters( 'authenticate' );
+
+ $user_id = $this->factory()->user->create( array( 'role' => 'administrator' ) );
+ add_user_meta( $user_id, User_Last_Seen::LAST_SEEN_META_KEY, strtotime( '-5 days' ) );
+
+ $user = get_user_by( 'id', $user_id );
+
+ $last_seen = new \Automattic\VIP\Security\User_Last_Seen();
+ $last_seen->init();
+
+ $new_user = apply_filters( 'authenticate', $user, $user );
+
+ $this->assertSame( $user->ID, $new_user->ID );
+ }
+
+ public function test__authenticate_should_return_an_error_when_user_is_inactive() {
+ Constant_Mocker::define( 'VIP_SECURITY_INACTIVE_USERS_ACTION', 'BLOCK' );
+ Constant_Mocker::define( 'VIP_SECURITY_CONSIDER_USERS_INACTIVE_AFTER_DAYS', 15 );
+ update_option( User_Last_Seen::LAST_SEEN_RELEASE_DATE_TIMESTAMP_OPTION_KEY, strtotime( '-100 days' ) );
+
+ remove_all_filters( 'authenticate' );
+
+ $user_id = $this->factory()->user->create( array(
+ 'role' => 'administrator',
+ 'user_registered' => '2020-01-01',
+ ) );
+ add_user_meta( $user_id, User_Last_Seen::LAST_SEEN_META_KEY, strtotime( '-100 days' ) );
+
+ $user = get_user_by( 'id', $user_id );
+
+ $last_seen = new \Automattic\VIP\Security\User_Last_Seen();
+ $last_seen->init();
+
+ $user = apply_filters( 'authenticate', $user, $user );
+
+ $this->assertWPError( $user, 'Expected WP_Error object to be returned' );
+ }
+
+ public function test__rest_authentication_should_return_an_error_when_user_is_inactive() {
+ Constant_Mocker::define( 'VIP_SECURITY_INACTIVE_USERS_ACTION', 'BLOCK' );
+ Constant_Mocker::define( 'VIP_SECURITY_CONSIDER_USERS_INACTIVE_AFTER_DAYS', 15 );
+ update_option( User_Last_Seen::LAST_SEEN_RELEASE_DATE_TIMESTAMP_OPTION_KEY, strtotime( '-100 days' ) );
+
+ remove_all_filters( 'wp_is_application_passwords_available_for_user' );
+ remove_all_filters( 'rest_authentication_errors' );
+
+ $user_id = $this->factory()->user->create( array(
+ 'role' => 'administrator',
+ 'user_registered' => '2020-01-01',
+ ) );
+ add_user_meta( $user_id, User_Last_Seen::LAST_SEEN_META_KEY, strtotime( '-100 days' ) );
+ $user = get_user_by( 'id', $user_id );
+
+ wp_set_current_user( $user_id );
+
+ $last_seen = new \Automattic\VIP\Security\User_Last_Seen();
+ $last_seen->init();
+
+ $available = apply_filters( 'wp_is_application_passwords_available_for_user', true, $user );
+ $this->assertFalse( $available );
+
+ $rest_authentication_errors = apply_filters( 'rest_authentication_errors', true );
+
+ $this->assertSame( 'inactive_account', $rest_authentication_errors->get_error_code() );
+ }
+
+ public function test__rest_authentication_should_not_block_when_action_is_not_block() {
+ Constant_Mocker::define( 'VIP_SECURITY_INACTIVE_USERS_ACTION', 'REPORT' );
+ Constant_Mocker::define( 'VIP_SECURITY_CONSIDER_USERS_INACTIVE_AFTER_DAYS', 15 );
+ update_option( User_Last_Seen::LAST_SEEN_RELEASE_DATE_TIMESTAMP_OPTION_KEY, strtotime( '-100 days' ) );
+
+ remove_all_filters( 'wp_is_application_passwords_available_for_user' );
+ remove_all_filters( 'rest_authentication_errors' );
+ remove_all_filters( 'determine_current_user' );
+
+ $user_id = $this->factory()->user->create( array(
+ 'role' => 'administrator',
+ 'user_registered' => '2020-01-01',
+ ) );
+ add_user_meta( $user_id, User_Last_Seen::LAST_SEEN_META_KEY, strtotime( '-16 days' ) );
+
+ $user = get_user_by( 'id', $user_id );
+
+ wp_set_current_user( $user_id );
+
+ $last_seen = new \Automattic\VIP\Security\User_Last_Seen();
+ $last_seen->init();
+
+ $available = apply_filters( 'wp_is_application_passwords_available_for_user', true, $user );
+ $this->assertTrue( $available );
+
+ $rest_authentication_errors = apply_filters( 'rest_authentication_errors', true );
+
+ $this->assertNotWPError( $rest_authentication_errors );
+ }
+
+ public function test__register_release_date_should_register_release_date_only_once() {
+ Constant_Mocker::define( 'VIP_SECURITY_INACTIVE_USERS_ACTION', 'RECORD_LAST_SEEN' );
+
+ remove_all_actions( 'admin_init' );
+ delete_option( User_Last_Seen::LAST_SEEN_RELEASE_DATE_TIMESTAMP_OPTION_KEY );
+ $last_seen = new \Automattic\VIP\Security\User_Last_Seen();
+ $last_seen->register_release_date();
+
+ $release_date = get_option( User_Last_Seen::LAST_SEEN_RELEASE_DATE_TIMESTAMP_OPTION_KEY );
+
+ $last_seen->register_release_date();
+
+ $new_release_date = get_option( User_Last_Seen::LAST_SEEN_RELEASE_DATE_TIMESTAMP_OPTION_KEY );
+
+ $this->assertIsNumeric( $release_date );
+ $this->assertSame( $release_date, $new_release_date );
+ }
+
+ public function test__authenticate_should_not_consider_users_without_elevated_capabilities() {
+ Constant_Mocker::define( 'VIP_SECURITY_INACTIVE_USERS_ACTION', 'BLOCK' );
+ Constant_Mocker::define( 'VIP_SECURITY_CONSIDER_USERS_INACTIVE_AFTER_DAYS', 15 );
+
+ remove_all_filters( 'authenticate' );
+
+ $user_id = $this->factory()->user->create( array( 'role' => 'subscriber' ) );
+ add_user_meta( $user_id, User_Last_Seen::LAST_SEEN_META_KEY, strtotime( '-100 days' ) );
+
+ $user = get_user_by( 'id', $user_id );
+
+ $last_seen = new \Automattic\VIP\Security\User_Last_Seen();
+ $last_seen->init();
+
+ $this->assertSame( $user, apply_filters( 'authenticate', $user, $user ) );
+ }
+
+ public function test__should_check_user_last_seen_should_call_elevated_capabilities_filters() {
+ Constant_Mocker::define( 'VIP_SECURITY_INACTIVE_USERS_ACTION', 'BLOCK' );
+ Constant_Mocker::define( 'VIP_SECURITY_CONSIDER_USERS_INACTIVE_AFTER_DAYS', 15 );
+
+ remove_all_filters( 'authenticate' );
+ remove_all_filters( 'vip_security_last_seen_elevated_capabilities' );
+
+ $user_id = $this->factory()->user->create( array(
+ 'role' => 'subscriber',
+ 'user_registered' => '2020-01-01',
+ ) );
+ add_user_meta( $user_id, User_Last_Seen::LAST_SEEN_META_KEY, strtotime( '-100 days' ) );
+
+ $user = get_user_by( 'id', $user_id );
+
+ $last_seen = new \Automattic\VIP\Security\User_Last_Seen();
+ $last_seen->init();
+
+ add_filter( 'vip_security_last_seen_elevated_capabilities', function ( $capabilities ) {
+ $capabilities[] = 'read';
+
+ return $capabilities;
+ } );
+
+ $user = apply_filters( 'authenticate', $user, $user );
+
+ $this->assertWPError( $user, 'Expected WP_Error object to be returned' );
+ }
+
+ public function test__should_check_user_last_seen_should_call_skip_users_filters() {
+ Constant_Mocker::define( 'VIP_SECURITY_INACTIVE_USERS_ACTION', 'BLOCK' );
+ Constant_Mocker::define( 'VIP_SECURITY_CONSIDER_USERS_INACTIVE_AFTER_DAYS', 15 );
+
+ remove_all_filters( 'authenticate' );
+ remove_all_filters( 'vip_security_last_seen_elevated_capabilities' );
+
+ $user_id = $this->factory()->user->create( array( 'role' => 'subscriber' ) );
+ add_user_meta( $user_id, User_Last_Seen::LAST_SEEN_META_KEY, strtotime( '-100 days' ) );
+
+ $user = get_user_by( 'id', $user_id );
+
+ $last_seen = new \Automattic\VIP\Security\User_Last_Seen();
+ $last_seen->init();
+
+ add_filter( 'vip_security_last_seen_skip_users', function ( $users ) use ( $user_id ) {
+ $users[] = $user_id;
+
+ return $users;
+ } );
+
+ $this->assertSame( $user, apply_filters( 'authenticate', $user, $user ) );
+ }
+
+ public function test__record_activity_should_be_stored_only_once() {
+ Constant_Mocker::define( 'VIP_SECURITY_INACTIVE_USERS_ACTION', 'BLOCK' );
+ Constant_Mocker::define( 'VIP_SECURITY_CONSIDER_USERS_INACTIVE_AFTER_DAYS', 15 );
+
+ remove_all_filters( 'determine_current_user' );
+
+ $user_id = $this->factory()->user->create( array(
+ 'role' => 'subscriber',
+ 'user_registered' => '2020-01-01',
+ ) );
+ $first_last_seen = strtotime( '-100 days' );
+ add_user_meta( $user_id, User_Last_Seen::LAST_SEEN_META_KEY, $first_last_seen );
+
+ wp_set_current_user( $user_id );
+
+ $last_seen = new \Automattic\VIP\Security\User_Last_Seen();
+ $last_seen->init();
+
+ apply_filters( 'determine_current_user', $user_id );
+ $current_last_seen = get_user_meta( $user_id, User_Last_Seen::LAST_SEEN_META_KEY, true );
+ $this->assertIsNumeric( $current_last_seen );
+ $this->assertNotEquals( $first_last_seen, $current_last_seen );
+
+ $test_value = 12345;
+ update_user_meta( $user_id, User_Last_Seen::LAST_SEEN_META_KEY, $test_value );
+
+ apply_filters( 'determine_current_user', $user_id );
+ $current_last_seen = get_user_meta( $user_id, User_Last_Seen::LAST_SEEN_META_KEY, true );
+ $this->assertEquals( $test_value, $current_last_seen );
+ }
+}
diff --git a/vip-support/class-vip-support-user.php b/vip-support/class-vip-support-user.php
index 1173eadbb1..3c9d306e10 100644
--- a/vip-support/class-vip-support-user.php
+++ b/vip-support/class-vip-support-user.php
@@ -1036,6 +1036,8 @@ public static function add( $user_data ) {
grant_super_admin( $user->ID );
}
+ do_action( 'vip_support_user_added', $user_id );
+
return $user_id;
}