Match accounts by mail address and user principal name
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

Sign-in and sync only used the first address (the mail attribute). A
WordPress account that carries the user principal name while the
Microsoft mail differs was not found at sign-in ("no WordPress account")
and the sync created a duplicate account for it.

Both now try the mail address and then the user principal name (UPN
only for members, only with the UPN fallback on, and only within the
e-mail domain allow-list). Privileged accounts keep the stricter rule;
a matching UPN is sufficient there as well.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Friederich Loheide 2026-09-24 04:23:02 +00:00
parent 81b3a74ae5
commit cc88f145f2
4 changed files with 34 additions and 14 deletions

View file

@ -27,6 +27,9 @@ All notable changes to this project are documented in this file. The format foll
- "Link Microsoft account" on the profile screen (the signed-in user binds their own Microsoft account) and an administrator-assigned Microsoft account (UPN) per user for administrators whose user principal name differs from their e-mail address. Sign-in finds bound accounts by object ID first, then by assigned UPN, then by e-mail.
- Privacy exporter and eraser for the data the plugin copies.
### Fixed
- Accounts whose WordPress e-mail is the user principal name (while the Microsoft mail differs) were not found at sign-in and got a duplicate account from the sync; sign-in and sync now try the mail address and the UPN.
### Security
Third audit (details: docs/security-audit.md, section 7): XML-RPC `system.multicall` bypass of button-only mode closed; privileged-account rules extended (bind_oid off, multisite-wide capabilities, code/HTML capabilities, deactivated administrators); cookie protection on WordPress 6.0/6.1; atomic and refreshed run lock with crash report; per-account tenant for deprovisioning; demotion safety stop; safety stop based on accounts linked before the run; photos re-encoded and removed on deactivation; one-time hardening of accounts deactivated by 1.0.

View file

@ -224,7 +224,7 @@ Alles wird live in der Vorschau angezeigt, bevor du speicherst.
| Option | Standard | Beschreibung |
| --- | --- | --- |
| Konto an Microsoft-Objekt-ID binden | an | Beim ersten Login wird die `oid` gespeichert; danach muss sie übereinstimmen. Schützt vor Übernahme, wenn eine E-Mail-Adresse in Microsoft neu vergeben wird. |
| UPN-Fallback | an | Fehlt der `email`-Claim, wird der User Principal Name verwendet, sofern er eine gültige E-Mail-Adresse ist. |
| UPN-Fallback | an | Neben dem `email`-Claim wird auch der User Principal Name gesucht wichtig, wenn WordPress-Konten den UPN statt der Mailadresse tragen. Der Abgleich läuft in der Reihenfolge: gebundene Objekt-ID → zugewiesener UPN → Mailadresse → UPN. |
| Angemeldet bleiben | aus | 14-Tage-Session statt Browser-Session. |
| Erlaubte E-Mail-Domains | leer | Kommagetrennte Liste, z. B. `contoso.com, contoso.de`. |
@ -322,7 +322,7 @@ automatisch per WP-Cron (stündlich, zweimal täglich, täglich) oder per WP-CLI
| Situation | Ergebnis |
| --- | --- |
| Kein WordPress-Konto vorhanden | Konto wird angelegt: Benutzername aus der E-Mail, Zufallspasswort, **keine E-Mail an den Benutzer**, Standardrolle + zugeordnete Rollen. Die Anmeldung läuft über den Microsoft-Button. |
| Konto mit derselben E-Mail existiert schon | Wird mit der Microsoft-Objekt-ID verknüpft, Profilfelder werden aktualisiert. Rollen bleiben unangetastet, außer *„Auch die Rollen von Konten verwalten, die schon vor dem Sync existierten“* ist aktiv. |
| Konto mit derselben E-Mail (Mailadresse **oder** UPN) existiert schon | Wird mit der Microsoft-Objekt-ID verknüpft, Profilfelder werden aktualisiert. Rollen bleiben unangetastet, außer *„Auch die Rollen von Konten verwalten, die schon vor dem Sync existierten“* ist aktiv. |
| Bereits verknüpft | E-Mail-Adresse, Profilfelder, Profilbild und (bei importierten Konten) Rollen werden aktualisiert. |
| In Microsoft 365 **deaktiviert** | Wahlweise nichts tun, WordPress-Konto **deaktivieren** oder **löschen**. |
| In Microsoft 365 **gelöscht** | Wahlweise nichts tun, deaktivieren oder löschen. |

View file

@ -533,22 +533,30 @@ class M365_Login_Auth {
$this->link_account( (int) $attempt['link_user'], $claims, $oid );
}
$email = $this->email_from_claims( $claims );
if ( '' !== $email && ! $this->domain_allowed( $email ) ) {
// E-mail and user principal name may differ: every usable address is tried (on the domain allow-list).
$candidates = $this->email_candidates( $claims );
$email = $candidates ? $candidates[0] : '';
$allowed = array_values( array_filter( $candidates, array( $this, 'domain_allowed' ) ) );
if ( $candidates && ! $allowed ) {
$this->fail( 'domain_not_allowed' );
}
// 1. An account already bound to this Microsoft identity, 2. an account the administrator
// assigned this user principal name to, 3. the e-mail address.
// assigned this user principal name to, 3. the e-mail address, then the user principal name.
$user = $this->find_bound_user( $oid );
if ( ! $user ) {
$user = $this->find_assigned_user( $claims );
}
if ( ! $user ) {
if ( '' === $email ) {
if ( ! $allowed ) {
$this->fail( 'no_email' );
}
$user = get_user_by( 'email', $email );
foreach ( $allowed as $candidate ) {
$user = get_user_by( 'email', $candidate );
if ( $user instanceof WP_User ) {
break;
}
}
}
if ( ! $user instanceof WP_User ) {
/** This action is documented in wp-includes/user.php */
@ -1072,12 +1080,12 @@ class M365_Login_Auth {
}
/**
* Extracts the e-mail address used for matching.
* Addresses used for matching, in order (e-mail claim, then user principal name).
*
* @param array $claims Verified claims.
* @return string Lowercase e-mail or empty string.
* @return string[] Lowercase addresses.
*/
private function email_from_claims( $claims ) {
private function email_candidates( $claims ) {
$candidates = array();
$email = ! empty( $claims['email'] ) && is_string( $claims['email'] ) ? $claims['email'] : '';
$upn = ! empty( $claims['preferred_username'] ) && is_string( $claims['preferred_username'] ) ? $claims['preferred_username'] : '';
@ -1101,19 +1109,23 @@ class M365_Login_Auth {
}
}
$out = array();
foreach ( $candidates as $candidate ) {
$candidate = strtolower( trim( $candidate ) );
if ( is_email( $candidate ) ) {
/**
* Filters the e-mail address used to look up the WordPress user.
* Filters an e-mail address used to look up the WordPress user.
*
* @param string $email E-mail from the token.
* @param array $claims Verified claims.
*/
return (string) apply_filters( 'm365_login_match_email', $candidate, $claims );
$candidate = strtolower( (string) apply_filters( 'm365_login_match_email', $candidate, $claims ) );
if ( is_email( $candidate ) && ! in_array( $candidate, $out, true ) ) {
$out[] = $candidate;
}
}
}
return '';
return $out;
}
/**

View file

@ -749,6 +749,11 @@ class M365_Login_Sync {
if ( ! $by_mail ) {
$by_mail = get_user_by( 'email', $email );
}
// The WordPress account may use the user principal name instead of the mail address.
$upn = self::member_upn( $person );
if ( ! $by_mail && '' !== $upn && $upn !== $email && is_email( $upn ) && $this->domain_allowed( $upn ) ) {
$by_mail = get_user_by( 'email', $upn );
}
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 ) {
@ -1294,7 +1299,7 @@ class M365_Login_Sync {
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 );
return ( '' !== $assigned && $assigned === $upn ) || strtolower( $user->user_email ) === $upn;
}
/**