Let administrators link Microsoft accounts whose UPN differs from mail
Some checks are pending
CI / PHP lint (7.4) (pull_request) Waiting to run
CI / PHP lint (8.0) (pull_request) Waiting to run
CI / PHP lint (8.1) (pull_request) Waiting to run
CI / PHP lint (8.2) (pull_request) Waiting to run
CI / PHP lint (8.3) (pull_request) Waiting to run
CI / PHP lint (8.4) (pull_request) Waiting to run
CI / WordPress Coding Standards (pull_request) Waiting to run
CI / WordPress.org Plugin Check (pull_request) Waiting to run

Privileged accounts are never linked through the settable mail
attribute. Two new ways make that workable when UPN and e-mail differ:

- "Link Microsoft account" on the profile screen: the signed-in user
  (nonce, same browser via the state cookie, same user at the callback)
  signs in with Microsoft once and binds that identity. Existing links
  can only be removed by an administrator; an object ID bound elsewhere
  is refused.
- "Assigned Microsoft account (UPN)" per user, editable by
  administrators, used by sign-in and user sync; with an option to
  remove a link.

Sign-in now finds accounts by bound object ID first, then by assigned
UPN, then by e-mail, so linked users sign in whatever their addresses.

Also: third-audit report (docs/security-audit.md section 7), README
section on linking administrator accounts, translations, tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Friederich Loheide 2026-09-24 03:57:45 +00:00
parent 9b893e42bc
commit 81b3a74ae5
12 changed files with 1583 additions and 995 deletions

View file

@ -108,6 +108,8 @@ class M365_Login_Sync {
add_action( 'admin_post_' . self::POST_STATE, array( $this, 'handle_user_state' ) );
add_action( 'show_user_profile', array( $this, 'profile_section' ) );
add_action( 'edit_user_profile', array( $this, 'profile_section' ) );
add_action( 'personal_options_update', array( $this, 'save_profile' ) );
add_action( 'edit_user_profile_update', array( $this, 'save_profile' ) );
add_action( 'admin_notices', array( $this, 'user_state_notice' ) );
}
@ -529,7 +531,8 @@ class M365_Login_Sync {
}
// Large directories: keep memory flat and the run lock fresh.
if ( 0 === ++$done % 250 ) {
++$done;
if ( 0 === $done % 250 ) {
if ( function_exists( 'wp_cache_flush_runtime' ) ) {
wp_cache_flush_runtime();
}
@ -739,9 +742,13 @@ class M365_Login_Sync {
return null;
}
// Not linked yet: match an existing account by e-mail address.
// Not linked yet: the account an administrator assigned this user principal name to,
// otherwise an existing account with the same e-mail address.
if ( ! $user ) {
$by_mail = get_user_by( 'email', $email );
$by_mail = $this->assigned_user( $person );
if ( ! $by_mail ) {
$by_mail = get_user_by( 'email', $email );
}
if ( $by_mail instanceof WP_User ) {
$stored = strtolower( (string) get_user_meta( $by_mail->ID, M365_Login_Auth::META_OID, true ) );
if ( '' !== $stored && $stored !== $oid ) {
@ -751,7 +758,7 @@ class M365_Login_Sync {
}
if ( ! $this->may_link( $by_mail, $person, $email ) ) {
/* translators: %s: e-mail address */
$this->skip( sprintf( __( '%s: privileged WordPress account it is only linked when the Microsoft user principal name equals its e-mail address (member account, no guest). Skipped.', 'm365-login' ), $email ) );
$this->skip( sprintf( __( '%s: privileged WordPress account linked only when the Microsoft user principal name equals its e-mail address or the Microsoft account assigned in its profile, or when the person links it from the profile. Skipped.', 'm365-login' ), $email ) );
return null;
}
$user = $by_mail;
@ -1282,9 +1289,51 @@ class M365_Login_Sync {
if ( ! self::is_privileged( $user ) ) {
return true;
}
$upn = isset( $person['userPrincipalName'] ) ? strtolower( (string) $person['userPrincipalName'] ) : '';
$upn = self::member_upn( $person );
if ( '' === $upn ) {
return false;
}
$assigned = strtolower( (string) get_user_meta( $user->ID, M365_Login_Auth::META_UPN, true ) );
return ( '' !== $assigned && $assigned === $upn ) || ( strtolower( $user->user_email ) === $upn && $upn === $email );
}
/**
* User principal name of a member (not a guest), lowercase, or ''.
*
* @param array $person Graph user.
* @return string
*/
private static function member_upn( $person ) {
$upn = isset( $person['userPrincipalName'] ) ? strtolower( trim( (string) $person['userPrincipalName'] ) ) : '';
$guest = isset( $person['userType'] ) && 'Guest' === $person['userType'];
return ! $guest && '' !== $upn && false === strpos( $upn, '#ext#' ) && strtolower( $user->user_email ) === $upn && $upn === $email;
return $guest || false !== strpos( $upn, '#ext#' ) ? '' : $upn;
}
/**
* Account an administrator assigned this person's user principal name to.
*
* @param array $person Graph user.
* @return WP_User|null
*/
private function assigned_user( $person ) {
$upn = self::member_upn( $person );
if ( '' === $upn ) {
return null;
}
$ids = get_users(
array(
'meta_key' => M365_Login_Auth::META_UPN, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => $upn, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
'fields' => 'ID',
'number' => 2,
'blog_id' => 0,
)
);
if ( 1 !== count( $ids ) ) {
return null;
}
$user = get_userdata( (int) $ids[0] );
return $user ? $user : null;
}
/**
@ -1887,7 +1936,7 @@ class M365_Login_Sync {
}
$stored = get_user_meta( $user->ID, self::META_PHOTO, true );
if ( is_array( $stored ) && ! empty( $stored['file'] ) && self::is_photo_file( $stored['file'] ) ) {
$uploads = wp_get_upload_dir();
$uploads = wp_get_upload_dir();
$fields[ __( 'Profile photo', 'm365-login' ) ] = trailingslashit( $uploads['baseurl'] ) . $stored['file'];
}
$last = (int) get_user_meta( $user->ID, self::META_LAST_SYNC, true );
@ -2188,6 +2237,16 @@ class M365_Login_Sync {
* Confirmation after a row action.
*/
public function user_state_notice() {
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- display only.
if ( isset( $_GET['m365_linked'] ) ) {
printf( '<div class="notice notice-success is-dismissible"><p>%s</p></div>', esc_html__( 'Your Microsoft account is now linked. From now on you can sign in with the Microsoft button.', 'm365-login' ) );
}
$link_error = isset( $_GET['m365_link_error'] ) ? sanitize_key( wp_unslash( $_GET['m365_link_error'] ) ) : '';
// phpcs:enable WordPress.Security.NonceVerification.Recommended
if ( '' !== $link_error ) {
printf( '<div class="notice notice-error is-dismissible"><p>%s</p></div>', esc_html( M365_Login::instance()->auth->error_message( $link_error ) ) );
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only.
$state = isset( $_GET['m365_user_state'] ) ? sanitize_key( wp_unslash( $_GET['m365_user_state'] ) ) : '';
if ( '' === $state ) {
@ -2205,8 +2264,11 @@ class M365_Login_Sync {
* @param WP_User $user User being edited.
*/
public function profile_section( $user ) {
$oid = (string) get_user_meta( $user->ID, M365_Login_Auth::META_OID, true );
if ( '' === $oid && ! self::disabled_info( $user->ID ) ) {
$oid = (string) get_user_meta( $user->ID, M365_Login_Auth::META_OID, true );
$own = get_current_user_id() === $user->ID;
$is_admin = current_user_can( M365_Login_Admin::capability() ) && current_user_can( 'edit_user', $user->ID );
$can_link = $own && '' === $oid && $this->settings->is_configured() && ! self::disabled_info( $user->ID );
if ( '' === $oid && ! self::disabled_info( $user->ID ) && ! $can_link && ! $is_admin ) {
return;
}
$rows = array();
@ -2242,9 +2304,36 @@ class M365_Login_Sync {
$rows[ (string) $attribute['label'] ] = $value;
}
}
$assigned = (string) get_user_meta( $user->ID, M365_Login_Auth::META_UPN, true );
?>
<h2><?php esc_html_e( 'Microsoft 365', 'm365-login' ); ?></h2>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><?php esc_html_e( 'Microsoft account', 'm365-login' ); ?></th>
<td>
<?php if ( '' !== $oid ) : ?>
<?php esc_html_e( 'Linked', 'm365-login' ); ?>
<?php else : ?>
<?php esc_html_e( 'Not linked', 'm365-login' ); ?>
<?php endif; ?>
<?php if ( $can_link ) : ?>
<p><a class="button" href="<?php echo esc_url( M365_Login::instance()->auth->link_url() ); ?>"><?php esc_html_e( 'Link Microsoft account', 'm365-login' ); ?></a></p>
<p class="description"><?php esc_html_e( 'You sign in with Microsoft once; afterwards this WordPress account is bound to that Microsoft account, even if its user principal name differs from your e-mail address.', 'm365-login' ); ?></p>
<?php endif; ?>
</td>
</tr>
<?php if ( $is_admin ) : ?>
<tr>
<th scope="row"><label for="m365-login-upn"><?php esc_html_e( 'Assigned Microsoft account (UPN)', 'm365-login' ); ?></label></th>
<td>
<input type="text" class="regular-text code" id="m365-login-upn" name="m365_login_upn" value="<?php echo esc_attr( $assigned ); ?>" placeholder="name@contoso.onmicrosoft.com" autocomplete="off" spellcheck="false" />
<p class="description"><?php esc_html_e( 'Optional. The user principal name of the Microsoft account that belongs to this user. Sign-in and user sync link exactly this account needed for administrators whose user principal name differs from their WordPress e-mail address.', 'm365-login' ); ?></p>
<?php if ( '' !== $oid ) : ?>
<label><input type="checkbox" name="m365_login_unlink" value="1" /> <?php esc_html_e( 'Remove the link to the Microsoft account', 'm365-login' ); ?></label>
<?php endif; ?>
</td>
</tr>
<?php endif; ?>
<?php foreach ( $rows as $label => $value ) : ?>
<tr>
<th scope="row"><?php echo esc_html( $label ); ?></th>
@ -2252,7 +2341,36 @@ class M365_Login_Sync {
</tr>
<?php endforeach; ?>
</table>
<p class="description"><?php esc_html_e( 'These values are managed by the Microsoft 365 user sync and overwritten on the next run.', 'm365-login' ); ?></p>
<?php if ( $rows ) : ?>
<p class="description"><?php esc_html_e( 'These values are managed by the Microsoft 365 user sync and overwritten on the next run.', 'm365-login' ); ?></p>
<?php endif; ?>
<?php
}
/**
* Saves the administrator fields of the Microsoft 365 profile section.
*
* Core has verified the "update-user_{$user_id}" nonce before these hooks run.
*
* @param int $user_id User being saved.
*/
public function save_profile( $user_id ) {
if ( ! current_user_can( M365_Login_Admin::capability() ) || ! current_user_can( 'edit_user', $user_id ) ) {
return;
}
// phpcs:disable WordPress.Security.NonceVerification.Missing -- verified by core (update-user_{$user_id}).
if ( isset( $_POST['m365_login_upn'] ) && is_string( $_POST['m365_login_upn'] ) ) {
$upn = strtolower( trim( sanitize_text_field( wp_unslash( $_POST['m365_login_upn'] ) ) ) );
if ( '' === $upn ) {
delete_user_meta( $user_id, M365_Login_Auth::META_UPN );
} elseif ( is_email( $upn ) ) {
update_user_meta( $user_id, M365_Login_Auth::META_UPN, $upn );
}
}
if ( ! empty( $_POST['m365_login_unlink'] ) ) {
delete_user_meta( $user_id, M365_Login_Auth::META_OID );
delete_user_meta( $user_id, M365_Login_Auth::META_TID );
}
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
}