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 = "
User blocked due to inactivity. " . __( 'Unblock', 'wpvip' ) . '
'; + } + 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( '

%2$s

', 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( '

%2$s

', 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; }