diff --git a/src/js/_enqueues/admin/user-profile.js b/src/js/_enqueues/admin/user-profile.js index 23d10404129fe..dc56d48630191 100644 --- a/src/js/_enqueues/admin/user-profile.js +++ b/src/js/_enqueues/admin/user-profile.js @@ -512,13 +512,17 @@ if ( select.length ) { $('#first_name, #last_name, #nickname').on( 'blur.user_profile', function() { var dub = [], + isEmail = $('#user_login').attr( 'data-is-email' ) === 'true', inputs = { display_nickname : $('#nickname').val() || '', - display_username : $('#user_login').val() || '', display_firstname : $('#first_name').val() || '', display_lastname : $('#last_name').val() || '' }; + if ( ! isEmail ) { + inputs.display_username = $('#user_login').val() || ''; + } + if ( inputs.display_firstname && inputs.display_lastname ) { inputs.display_firstlast = inputs.display_firstname + ' ' + inputs.display_lastname; inputs.display_lastfirst = inputs.display_lastname + ' ' + inputs.display_firstname; diff --git a/src/wp-admin/user-edit.php b/src/wp-admin/user-edit.php index d1f99690b8451..e8ca2cd8f1937 100644 --- a/src/wp-admin/user-edit.php +++ b/src/wp-admin/user-edit.php @@ -472,7 +472,7 @@ - + ID ) ) : ?> @@ -551,7 +551,10 @@ nickname; + + if ( ! is_email( $profile_user->user_login ) ) { $public_display['display_username'] = $profile_user->user_login; + } if ( ! empty( $profile_user->first_name ) ) { $public_display['display_firstname'] = $profile_user->first_name; diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index 02a412b291a8e..d6483046dbbc2 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -2198,6 +2198,8 @@ function validate_username( $username ) { * @since 5.3.0 The `user_activation_key` field can be passed to `$userdata`. * @since 5.3.0 The `spam` field can be passed to `$userdata` (Multisite only). * @since 5.9.0 The `meta_input` field can be passed to `$userdata` to allow addition of user meta data. + * @since 7.2.0 Default `display_name`, `nickname`, and `user_nicename` avoid using an email address + * when `$user_login` is an email address. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -2208,13 +2210,15 @@ function validate_username( $username ) { * @type string $user_pass The plain-text user password for new users. * Hashed password for existing users. * @type string $user_login The user's login username. - * @type string $user_nicename The URL-friendly user name. + * @type string $user_nicename The URL-friendly user name. Default is a URL-safe version + * of the user's login, or a generated slug (user-{random}) + * if the login is an email address. * @type string $user_url The user URL. * @type string $user_email The user email address. - * @type string $display_name The user's display name. - * Default is the user's username. - * @type string $nickname The user's nickname. - * Default is the user's username. + * @type string $display_name The user's display name. Default is the user's username, + * or 'User {random}' if the username is an email address. + * @type string $nickname The user's nickname. Default is the user's username, + * or 'User {random}' if the username is an email address. * @type string $first_name The user's first name. For new users, will be used * to build the first part of the user's display name * if `$display_name` is not specified. @@ -2370,12 +2374,22 @@ function wp_insert_user( $userdata ) { return new WP_Error( 'invalid_username', __( 'Sorry, that username is not allowed.' ) ); } + $email_login_suffix = ''; + if ( is_email( $user_login ) ) { + $email_login_suffix = bin2hex( random_bytes( 4 ) ); + } + /* * If a nicename is provided, remove unsafe user characters before using it. - * Otherwise build a nicename from the user_login. + * If updating an email-login user and no nicename is provided, preserve existing nicename. + * Otherwise build a nicename from a random slug (if login is an email) or the user_login. */ if ( ! empty( $userdata['user_nicename'] ) ) { $user_nicename = sanitize_user( $userdata['user_nicename'], true ); + } elseif ( $update && is_email( $user_login ) && ! empty( $old_user_data->user_nicename ) ) { + $user_nicename = $old_user_data->user_nicename; + } elseif ( is_email( $user_login ) ) { + $user_nicename = 'user-' . $email_login_suffix; } else { $user_nicename = mb_substr( $user_login, 0, 50 ); } @@ -2463,7 +2477,11 @@ function wp_insert_user( $userdata ) { // Store values to save in user meta. $meta = array(); - $nickname = empty( $userdata['nickname'] ) ? $user_login : $userdata['nickname']; + if ( empty( $userdata['nickname'] ) ) { + $nickname = is_email( $user_login ) ? 'User ' . $email_login_suffix : $user_login; + } else { + $nickname = $userdata['nickname']; + } /** * Filters a user's nickname before the user is created or updated. @@ -2498,7 +2516,7 @@ function wp_insert_user( $userdata ) { if ( empty( $userdata['display_name'] ) ) { if ( $update ) { - $display_name = $user_login; + $display_name = is_email( $user_login ) ? 'User ' . $email_login_suffix : $user_login; } elseif ( $meta['first_name'] && $meta['last_name'] ) { $display_name = sprintf( /* translators: 1: User's first name, 2: Last name. */ @@ -2510,6 +2528,8 @@ function wp_insert_user( $userdata ) { $display_name = $meta['first_name']; } elseif ( $meta['last_name'] ) { $display_name = $meta['last_name']; + } elseif ( is_email( $user_login ) ) { + $display_name = 'User ' . $email_login_suffix; } else { $display_name = $user_login; } @@ -2581,8 +2601,10 @@ function wp_insert_user( $userdata ) { * @type string $user_pass The user's password. * @type string $user_email The user's email. * @type string $user_url The user's url. - * @type string $user_nicename The user's nice name. Defaults to a URL-safe version of user's login. - * @type string $display_name The user's display name. + * @type string $user_nicename The user's nice name. Defaults to a URL-safe version of user's login, + * or a generated slug (user-{random}) if the login is an email address. + * @type string $display_name The user's display name. Defaults to the user's username, or + * 'User {random}' if the username is an email address. * @type string $user_registered MySQL timestamp describing the moment when the user registered. Defaults to * the current UTC timestamp. * } diff --git a/tests/phpunit/tests/user/wpInsertUser.php b/tests/phpunit/tests/user/wpInsertUser.php new file mode 100644 index 0000000000000..1582e43c51e1e --- /dev/null +++ b/tests/phpunit/tests/user/wpInsertUser.php @@ -0,0 +1,501 @@ + $email, + 'user_email' => $email, + 'user_pass' => 'Password123!', + ) + ); + + $this->assertNotWPError( $user_id, 'wp_insert_user should succeed with an email login.' ); + + $user = get_userdata( $user_id ); + $this->assertInstanceOf( 'WP_User', $user, 'get_userdata should return a WP_User instance.' ); + + $this->assertMatchesRegularExpression( '/^User [0-9a-f]{8}$/', $user->display_name, 'display_name should default to "User " when login is an email.' ); + $this->assertMatchesRegularExpression( '/^User [0-9a-f]{8}$/', $user->nickname, 'nickname should default to "User " when login is an email.' ); + $this->assertMatchesRegularExpression( '/^user-[0-9a-f]{8}$/', $user->user_nicename, 'user_nicename should start with "user-" prefix followed by hex suffix.' ); + $suffix = substr( $user->user_nicename, 5 ); + $this->assertSame( 'User ' . $suffix, $user->display_name, 'display_name should share the same random suffix as user_nicename.' ); + $this->assertSame( 'User ' . $suffix, $user->nickname, 'nickname should share the same random suffix as user_nicename.' ); + $this->assertStringNotContainsString( '@', $user->user_nicename, 'user_nicename must not contain "@".' ); + $this->assertStringNotContainsString( 'example.com', $user->user_nicename, 'user_nicename must not contain the email domain.' ); + $this->assertLessThanOrEqual( 50, strlen( $user->user_nicename ), 'user_nicename must not exceed 50 characters.' ); + } + + /** + * Tests that first and last name take priority over the default display name when provided. + * + * @ticket 66446 + */ + public function test_wp_insert_user_with_email_login_respects_first_and_last_name_for_display_name() { + $email = 'jane.doe@example.com'; + $user_id = wp_insert_user( + array( + 'user_login' => $email, + 'user_email' => $email, + 'user_pass' => 'Password123!', + 'first_name' => 'Jane', + 'last_name' => 'Doe', + ) + ); + + $this->assertNotWPError( $user_id, 'wp_insert_user should succeed.' ); + + $user = get_userdata( $user_id ); + $this->assertSame( 'Jane Doe', $user->display_name, 'display_name should be built from first and last name.' ); + $this->assertMatchesRegularExpression( '/^User [0-9a-f]{8}$/', $user->nickname, 'nickname should still default to "User " when not explicitly supplied.' ); + } + + /** + * Tests that single first name or last name is used for display name when provided. + * + * @ticket 66446 + */ + public function test_wp_insert_user_with_email_login_respects_single_name_for_display_name() { + $email = 'onlyfirst@example.com'; + $user_id = wp_insert_user( + array( + 'user_login' => $email, + 'user_email' => $email, + 'user_pass' => 'Password123!', + 'first_name' => 'Alex', + ) + ); + + $this->assertNotWPError( $user_id, 'wp_insert_user should succeed.' ); + + $user = get_userdata( $user_id ); + $this->assertSame( 'Alex', $user->display_name, 'display_name should use first name when last name is empty.' ); + } + + /** + * Tests that explicitly supplied display_name and nickname are respected even if login is an email. + * + * @ticket 66446 + */ + public function test_wp_insert_user_with_email_login_respects_explicit_display_name_and_nickname() { + $email = 'custom.names@example.com'; + $user_id = wp_insert_user( + array( + 'user_login' => $email, + 'user_email' => $email, + 'user_pass' => 'Password123!', + 'display_name' => 'Custom Display', + 'nickname' => 'CustomNick', + ) + ); + + $this->assertNotWPError( $user_id, 'wp_insert_user should succeed.' ); + + $user = get_userdata( $user_id ); + $this->assertSame( 'Custom Display', $user->display_name, 'Explicit display_name should be preserved.' ); + $this->assertSame( 'CustomNick', $user->nickname, 'Explicit nickname should be preserved.' ); + } + + /** + * Tests that explicitly supplied user_nicename is respected even if login is an email. + * + * @ticket 66446 + */ + public function test_wp_insert_user_with_email_login_respects_explicit_user_nicename() { + $email = 'custom.slug@example.com'; + $user_id = wp_insert_user( + array( + 'user_login' => $email, + 'user_email' => $email, + 'user_pass' => 'Password123!', + 'user_nicename' => 'custom-author-slug', + ) + ); + + $this->assertNotWPError( $user_id, 'wp_insert_user should succeed.' ); + + $user = get_userdata( $user_id ); + $this->assertSame( 'custom-author-slug', $user->user_nicename, 'Explicit user_nicename should be preserved.' ); + } + + /** + * Tests that standard non-email usernames continue to use the username as fallback for display_name, + * nickname, and user_nicename. + * + * @ticket 66446 + */ + public function test_wp_insert_user_with_standard_username_preserves_username_fallbacks() { + $username = 'standarduser'; + $user_id = wp_insert_user( + array( + 'user_login' => $username, + 'user_email' => 'standarduser@example.com', + 'user_pass' => 'Password123!', + ) + ); + + $this->assertNotWPError( $user_id, 'wp_insert_user should succeed with standard username.' ); + + $user = get_userdata( $user_id ); + $this->assertSame( $username, $user->display_name, 'display_name should default to standard username.' ); + $this->assertSame( $username, $user->nickname, 'nickname should default to standard username.' ); + $this->assertSame( $username, $user->user_nicename, 'user_nicename should default to sanitized username.' ); + } + + /** + * Tests that multiple users with email logins get distinct user_nicename values. + * + * @ticket 66446 + */ + public function test_wp_insert_user_with_email_login_generates_unique_nicenames() { + $email1 = 'user.one@example.com'; + $email2 = 'user.two@example.com'; + $user_id1 = wp_insert_user( + array( + 'user_login' => $email1, + 'user_email' => $email1, + 'user_pass' => 'Password123!', + ) + ); + $user_id2 = wp_insert_user( + array( + 'user_login' => $email2, + 'user_email' => $email2, + 'user_pass' => 'Password123!', + ) + ); + + $this->assertNotWPError( $user_id1, 'User 1 creation should succeed.' ); + $this->assertNotWPError( $user_id2, 'User 2 creation should succeed.' ); + + $user1 = get_userdata( $user_id1 ); + $user2 = get_userdata( $user_id2 ); + + $this->assertNotSame( $user1->user_nicename, $user2->user_nicename, 'Generated user_nicename values must be unique.' ); + $this->assertNotSame( $user1->display_name, $user2->display_name, 'Generated display_name values must be unique.' ); + $this->assertNotSame( $user1->nickname, $user2->nickname, 'Generated nickname values must be unique.' ); + } + + /** + * Tests that updating a user with an email username preserves the existing user_nicename + * when user_nicename is not passed in the update data. + * + * @ticket 66446 + */ + public function test_wp_insert_user_update_preserves_existing_user_nicename_for_email_login() { + $email = 'update.member@example.com'; + $user_id = wp_insert_user( + array( + 'user_login' => $email, + 'user_email' => $email, + 'user_pass' => 'Password123!', + ) + ); + + $user_before = get_userdata( $user_id ); + $orig_nicename = $user_before->user_nicename; + + // Perform an update without supplying user_nicename. + $updated_id = wp_update_user( + array( + 'ID' => $user_id, + 'description' => 'Updated bio description.', + ) + ); + + $this->assertSame( $user_id, $updated_id, 'wp_update_user should return the user ID.' ); + + $user_after = get_userdata( $user_id ); + $this->assertSame( $orig_nicename, $user_after->user_nicename, 'Updating a user must preserve existing user_nicename.' ); + $this->assertSame( 'Updated bio description.', $user_after->description, 'User description should be updated.' ); + } + + /** + * Tests that wp_create_user() also defaults display_name and nickname to 'User' + * when called with an email address as username. + * + * @ticket 66446 + */ + public function test_wp_create_user_with_email_login_defaults_display_name_and_nickname_to_user() { + $email = 'created.user@example.com'; + $user_id = wp_create_user( $email, 'Password123!', $email ); + + $this->assertNotWPError( $user_id, 'wp_create_user should succeed.' ); + + $user = get_userdata( $user_id ); + $this->assertMatchesRegularExpression( '/^User [0-9a-f]{8}$/', $user->display_name, 'display_name should default to "User ".' ); + $this->assertMatchesRegularExpression( '/^User [0-9a-f]{8}$/', $user->nickname, 'nickname should default to "User ".' ); + $this->assertMatchesRegularExpression( '/^user-[0-9a-f]{8}$/', $user->user_nicename, 'user_nicename should start with "user-".' ); + } + + /** + * Tests that complex email address formats as login are treated as email logins. + * + * @ticket 66446 + */ + public function test_wp_insert_user_with_complex_email_login() { + $email = 'First.Last+tag@sub.domain.example.co.uk'; + $user_id = wp_insert_user( + array( + 'user_login' => $email, + 'user_email' => $email, + 'user_pass' => 'Password123!', + ) + ); + + $this->assertNotWPError( $user_id, 'wp_insert_user should succeed with complex email format.' ); + + $user = get_userdata( $user_id ); + $this->assertMatchesRegularExpression( '/^User [0-9a-f]{8}$/', $user->display_name, 'display_name should default to "User " for complex email logins.' ); + $this->assertMatchesRegularExpression( '/^User [0-9a-f]{8}$/', $user->nickname, 'nickname should default to "User " for complex email logins.' ); + $this->assertMatchesRegularExpression( '/^user-[0-9a-f]{8}$/', $user->user_nicename, 'user_nicename should use random slug prefix.' ); + $this->assertStringNotContainsString( 'First.Last', $user->user_nicename, 'user_nicename must not leak the email local-part.' ); + $this->assertStringNotContainsString( 'sub.domain', $user->user_nicename, 'user_nicename must not leak the email domain.' ); + } + + /** + * Tests that filters pre_user_display_name, pre_user_nickname, and pre_user_nicename + * can override the default values when desired. + * + * @ticket 66446 + */ + public function test_wp_insert_user_with_email_login_allows_filter_overrides() { + $filter_display = static function () { + return 'Filtered Display'; + }; + $filter_nickname = static function () { + return 'Filtered Nick'; + }; + $filter_nicename = static function () { + return 'filtered-slug'; + }; + + add_filter( 'pre_user_display_name', $filter_display ); + add_filter( 'pre_user_nickname', $filter_nickname ); + add_filter( 'pre_user_nicename', $filter_nicename ); + + $email = 'filterable@example.com'; + $user_id = wp_insert_user( + array( + 'user_login' => $email, + 'user_email' => $email, + 'user_pass' => 'Password123!', + ) + ); + + $user = get_userdata( $user_id ); + $this->assertSame( 'Filtered Display', $user->display_name, 'pre_user_display_name filter should override display_name.' ); + $this->assertSame( 'Filtered Nick', $user->nickname, 'pre_user_nickname filter should override nickname.' ); + $this->assertSame( 'filtered-slug', $user->user_nicename, 'pre_user_nicename filter should override user_nicename.' ); + } + + /** + * Tests that email usernames do not leak into downstream consumer outputs: + * post bylines, feeds, comment authors, and REST API user responses. + * + * @ticket 66446 + */ + public function test_email_login_downstream_privacy_in_bylines_comments_and_rest() { + $email = 'author.privacy@example.com'; + $user_id = wp_insert_user( + array( + 'user_login' => $email, + 'user_email' => $email, + 'user_pass' => 'Password123!', + 'role' => 'author', + ) + ); + + $this->assertNotWPError( $user_id, 'Author creation should succeed.' ); + + // 1. Post byline & author link. + $post_id = self::factory()->post->create( + array( + 'post_author' => $user_id, + 'post_title' => 'Sample Post', + 'post_content' => 'Sample content.', + ) + ); + + $post = get_post( $post_id ); + $GLOBALS['post'] = $post; + $GLOBALS['authordata'] = get_userdata( $user_id ); + + $byline = get_the_author(); + $this->assertStringNotContainsString( '@', $byline, 'Post byline must not contain "@".' ); + $this->assertStringNotContainsString( 'example.com', $byline, 'Post byline must not contain the email domain.' ); + $this->assertMatchesRegularExpression( '/^User [0-9a-f]{8}$/', $byline, 'Post byline should match "User ".' ); + + $author_url = get_author_posts_url( $user_id ); + $this->assertStringNotContainsString( '@', $author_url, 'Author posts URL must not contain "@".' ); + $this->assertStringNotContainsString( 'example.com', $author_url, 'Author posts URL must not contain the email domain.' ); + + // 2. Comments via comment submission by logged-in user. + wp_set_current_user( $user_id ); + $comment = wp_handle_comment_submission( + array( + 'comment_post_ID' => $post_id, + 'comment' => 'A test comment.', + 'comment_parent' => 0, + ) + ); + + $this->assertInstanceOf( 'WP_Comment', $comment, 'wp_handle_comment_submission should return a WP_Comment.' ); + $this->assertStringNotContainsString( '@', $comment->comment_author, 'Comment author must not contain "@".' ); + $this->assertStringNotContainsString( 'example.com', $comment->comment_author, 'Comment author must not contain email domain.' ); + $this->assertMatchesRegularExpression( '/^User [0-9a-f]{8}$/', $comment->comment_author, 'Comment author should match "User ".' ); + + // 3. REST API response via controller. + $controller = new WP_REST_Users_Controller(); + $request = new WP_REST_Request( 'GET', '/wp/v2/users/' . $user_id ); + $request->set_param( 'id', $user_id ); + $request->set_param( 'context', 'view' ); + $response = $controller->get_item( $request ); + $data = $response->get_data(); + + $this->assertStringNotContainsString( '@', $data['name'], 'REST user name must not contain "@".' ); + $this->assertStringNotContainsString( 'example.com', $data['name'], 'REST user name must not contain email domain.' ); + $this->assertStringNotContainsString( '@', $data['slug'], 'REST user slug must not contain "@".' ); + $this->assertStringNotContainsString( 'example.com', $data['slug'], 'REST user slug must not contain email domain.' ); + } + + /** + * Tests that user profile public display options omit user_login when it is an email address. + * + * @ticket 66446 + */ + public function test_profile_screen_public_display_options_omits_email_username() { + $email = 'profile.email@example.com'; + $user_id = wp_insert_user( + array( + 'user_login' => $email, + 'user_email' => $email, + 'user_pass' => 'Password123!', + ) + ); + + $profile_user = get_userdata( $user_id ); + $public_display = array(); + $public_display['display_nickname'] = $profile_user->nickname; + + if ( ! is_email( $profile_user->user_login ) ) { + $public_display['display_username'] = $profile_user->user_login; + } + + $this->assertArrayNotHasKey( 'display_username', $public_display, 'display_username should be excluded from public display options when login is an email.' ); + $this->assertContains( $profile_user->nickname, $public_display, 'Nickname should be available in public display options.' ); + + // Standard username should include display_username. + $std_user_id = self::factory()->user->create( array( 'user_login' => 'standardprofile' ) ); + $std_user = get_userdata( $std_user_id ); + $std_display = array(); + $std_display['display_nickname'] = $std_user->nickname; + + if ( ! is_email( $std_user->user_login ) ) { + $std_display['display_username'] = $std_user->user_login; + } + + $this->assertArrayHasKey( 'display_username', $std_display, 'display_username should be included for standard usernames.' ); + } + + /** + * Tests that wpmu_create_user() safely anonymizes defaults when given an email username on Multisite. + * + * @ticket 66446 + * @group ms-required + */ + public function test_wpmu_create_user_with_email_login_anonymizes_defaults() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only.' ); + } + + $email = 'ms.member@example.com'; + $user_id = wpmu_create_user( $email, 'Password123!', $email ); + + $this->assertIsInt( $user_id, 'wpmu_create_user should succeed and return integer ID.' ); + + $user = get_userdata( $user_id ); + $this->assertMatchesRegularExpression( '/^User [0-9a-f]{8}$/', $user->display_name, 'display_name should default to "User " on Multisite.' ); + $this->assertMatchesRegularExpression( '/^User [0-9a-f]{8}$/', $user->nickname, 'nickname should default to "User " on Multisite.' ); + $this->assertMatchesRegularExpression( '/^user-[0-9a-f]{8}$/', $user->user_nicename, 'user_nicename should start with "user-" on Multisite.' ); + } + + /** + * Tests that updating an existing user whose display_name is already an email address + * preserves the display_name without altering it. + * + * @ticket 66446 + */ + public function test_wp_update_user_preserves_existing_email_display_name() { + // Simulate a legacy account created prior to this privacy change where display_name was saved as the email. + $user_id = wp_insert_user( + array( + 'user_login' => 'legacyuser', + 'user_email' => 'legacy@example.com', + 'user_pass' => 'Password123!', + 'display_name' => 'legacy@example.com', + ) + ); + + $user_before = get_userdata( $user_id ); + $this->assertSame( 'legacy@example.com', $user_before->display_name, 'Initial display_name should be set to email.' ); + + // Update another field without specifying display_name. + $updated_id = wp_update_user( + array( + 'ID' => $user_id, + 'description' => 'Updated biography text.', + ) + ); + + $this->assertSame( $user_id, $updated_id, 'wp_update_user should succeed.' ); + + $user_after = get_userdata( $user_id ); + $this->assertSame( 'legacy@example.com', $user_after->display_name, 'Existing display_name must remain unchanged after update.' ); + $this->assertSame( 'Updated biography text.', $user_after->description, 'Description should be updated.' ); + } + + /** + * Tests that updating a standard non-email user preserves or derives nicename correctly. + * + * @ticket 66446 + */ + public function test_wp_insert_user_update_with_standard_user_preserves_nicename() { + $user_id = wp_insert_user( + array( + 'user_login' => 'standardupdater', + 'user_email' => 'standardupdater@example.com', + 'user_pass' => 'Password123!', + ) + ); + + $user_before = get_userdata( $user_id ); + $this->assertSame( 'standardupdater', $user_before->user_nicename, 'Initial nicename should match login.' ); + + $updated_id = wp_update_user( + array( + 'ID' => $user_id, + 'description' => 'Bio update.', + ) + ); + + $this->assertSame( $user_id, $updated_id, 'wp_update_user should return the user ID.' ); + + $user_after = get_userdata( $user_id ); + $this->assertSame( 'standardupdater', $user_after->user_nicename, 'Standard user nicename should be preserved.' ); + } +}