diff --git a/CHANGELOG.md b/CHANGELOG.md index 52777be..a06c031 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,12 @@ All notable changes to this project are documented in this file. The format foll ### Changed - The group picker is reusable (security groups, sync groups, role mapping). +- "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. + ### 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. + Fixes from a full second security audit (details: docs/security-audit.md, section 6): - Multisite: settings, connection test, certificates and user sync require `manage_network_options` (a site admin could otherwise sign in as the super admin via an own tenant). - Administrator accounts are only linked (sync and first sign-in) through a matching user principal name of a member account, never through the freely settable `mail` attribute; their e-mail address is never changed by the sync; e-mail change notifications stay on for other accounts. diff --git a/README.md b/README.md index b3b66fa..0d8d55c 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ - [Nur-Button-Modus & Fallback](#nur-button-modus--fallback) - [Eigene Login-Seite](#eigene-login-seite) - [Benutzer-Sync](#benutzer-sync) + - [Administrator-Konten verknüpfen](#administrator-konten-verknüpfen) - [Sicherheitskonzept](#sicherheitskonzept) - [Shortcode & Hooks](#shortcode--hooks) - [Fehlerbehebung](#fehlerbehebung) @@ -304,12 +305,11 @@ Plugins, die `wp-login.php` umbenennen (z. B. WPS Hide Login), sind kompatibel, ### Benutzer-Sync -> **Administrator-Konten** (alle mit `manage_options`, `promote_users`, `edit_users` oder Super-Admin) werden nie über das -> `mail`-Attribut verknüpft, sondern nur, wenn der **User Principal Name** eines Mitglieds (kein Gast) exakt ihrer -> WordPress-E-Mail entspricht – das `mail`-Attribut kann jeder Benutzer- oder Exchange-Admin des Tenants frei setzen, der -> UPN nur auf verifizierten Domains. Dieselbe Regel gilt für die erste Microsoft-Anmeldung eines Administrators. Ihre -> E-Mail-Adresse ändert der Sync nie automatisch. Bei allen anderen Konten informiert WordPress die alte Adresse über eine -> Änderung. +> **Privilegierte Konten** (Administratoren, Redakteure mit `unfiltered_html`, alle mit Rechten an Benutzern, Plugins +> oder Themes – auf irgendeiner Site des Netzwerks – sowie deaktivierte Konten, die solche Rollen zurückbekämen) werden +> nie über das frei setzbare `mail`-Attribut verknüpft. Siehe [Administrator-Konten verknüpfen](#administrator-konten-verknüpfen). +> Ihre E-Mail-Adresse ändert der Sync nie automatisch. Bei allen anderen Konten informiert WordPress die alte Adresse über +> eine Änderung. Tab *Benutzer-Sync*. Legt WordPress-Konten für Microsoft-365-Benutzer an und hält sie aktuell – manuell per Knopfdruck, automatisch per WP-Cron (stündlich, zweimal täglich, täglich) oder per WP-CLI. @@ -385,6 +385,22 @@ wp m365-login sync --dry-run # Testlauf wp m365-login sync # echter Lauf ``` +### Administrator-Konten verknüpfen + +Das `mail`-Attribut in Entra ID kann jeder Benutzer- oder Exchange-Administrator des Tenants frei setzen – wer es auf die +Adresse eines WordPress-Admins setzt, dürfte sonst dessen Konto übernehmen. Privilegierte Konten werden deshalb nur auf +einem dieser Wege mit einem Microsoft-Konto verknüpft (per Anmeldung oder Sync): + +| Weg | Wann sinnvoll | +| --- | --- | +| **Selbst verknüpfen:** *Profil → Microsoft 365 → „Mit Microsoft-Konto verknüpfen“*. Die Person ist in WordPress angemeldet (beweist das WordPress-Konto) und meldet sich einmal bei Microsoft an (beweist das Microsoft-Konto). | Immer – auch wenn UPN und Mailadresse völlig verschieden sind. Im Nur-Button-Modus vorher über den Fallback-Link mit Passwort anmelden. | +| **Zuweisen:** Ein Administrator trägt beim Bearbeiten des Benutzers unter *Microsoft 365* den **UPN** ein (*Zugewiesenes Microsoft-Konto*). Anmeldung und Sync verknüpfen genau dieses Konto. | Mehrere Admins einrichten, ohne dass jeder selbst klicken muss. | +| **Automatisch:** UPN eines Mitglieds (kein Gast) = WordPress-E-Mail. | Wenn UPN und Mailadresse bei euch gleich sind. | + +Nach der Verknüpfung findet die Anmeldung das Konto über die unveränderliche Objekt-ID – E-Mail-Adresse oder UPN dürfen sich +danach ändern. Eine bestehende Verknüpfung kann nur ein Administrator aufheben (*Verknüpfung mit dem Microsoft-Konto +aufheben* im Profil); eine Person kann ihr Konto nicht selbst auf ein anderes Microsoft-Konto umhängen. + --- ## Sicherheitskonzept @@ -403,7 +419,7 @@ wp m365-login sync # echter Lauf | Secret-Diebstahl aus der Datenbank | AES-256-GCM, Schlüssel per HKDF aus `AUTH_KEY`/`SECURE_AUTH_KEY`; ohne `wp-config.php` ist der Datensatz wertlos. Gilt für Client Secret und privaten Zertifikatsschlüssel. | | Secret-Abfluss im Transport | Zertifikatsmodus: es wird nie ein Geheimnis übertragen, nur eine 5 Minuten gültige, signierte Client Assertion (RFC 7523). | | Kontoübernahme im Multi-Tenant-Modus | `email`-Claim fremder Tenants wird ignoriert (nur UPN mit verifizierter Domain oder `xms_edov`). | -| Flooding der State-Tabelle | Max. 30 Login-Starts pro IP und 10 Minuten; Proxy-Header per `M365_LOGIN_CLIENT_IP_HEADER`. | +| Flooding der State-Tabelle | Max. 300 Login-Starts pro IP und 10 Minuten; Proxy-Header per `M365_LOGIN_CLIENT_IP_HEADER` – am besten ein einwertiger Header wie `HTTP_CF_CONNECTING_IP` oder `HTTP_X_REAL_IP` (bei `X-Forwarded-For` zählt der rechte, vom Proxy geschriebene Eintrag). | | Offene Redirects | `redirect_to` läuft durch `wp_validate_redirect`, alle Redirects über `wp_safe_redirect`. | | Fehler-Reflektion | Fehlermeldungen sind Codes → feste, übersetzte Texte; Details nur ins Log (`WP_DEBUG_LOG`). | | Rate Limiting Fallback-Key | 10 Fehlversuche pro IP / 15 Min. | diff --git a/docs/security-audit.md b/docs/security-audit.md index 28ef627..189d4c4 100644 --- a/docs/security-audit.md +++ b/docs/security-audit.md @@ -1,6 +1,6 @@ # Security-Audit: M365 Login 1.1.0 -**Stand:** 23.09.2026 (Erst-Audit 22.09.2026, vollständiges Zweit-Audit 23.09.2026) · **Umfang:** gesamter Plugin-Code +**Stand:** 24.09.2026 (Erst-Audit 22.09.2026, Zweit-Audit 23.09.2026, Dritt-Audit 24.09.2026) · **Umfang:** gesamter Plugin-Code (PHP, JS, CSS) inkl. Benutzer-Sync, Konfiguration, Deployment-Hinweise · **Methode:** Code-Review gegen OAuth 2.0 / OpenID Connect Best Current Practice (RFC 6749, RFC 7636 PKCE, RFC 7523 Client Assertions, OAuth 2.0 Security BCP), OWASP ASVS 4.0 (V2 Authentication, V3 Session, V5 Validation, @@ -16,15 +16,16 @@ echte HTTP-Requests (PHP-Webserver + curl) gegen `wp-login.php`, `xmlrpc.php` un ## 1. Zusammenfassung -| Schweregrad | Erst-Audit | Zweit-Audit | Behoben | Akzeptiert / dokumentiert | -| --- | --- | --- | --- | --- | -| Kritisch | 0 | 1 (nur Multisite) | 1 | 0 | -| Hoch | 1 | 4 | 5 | 0 | -| Mittel | 3 | 9 | 12 | 0 | -| Niedrig | 5 | 11 | 12 | 4 | -| Hinweis | 6 | 11 | 4 | 13 | +| Schweregrad | Erst-Audit | Zweit-Audit | Dritt-Audit | Behoben | Akzeptiert / dokumentiert | +| --- | --- | --- | --- | --- | --- | +| Kritisch | 0 | 1 (nur Multisite) | 0 | 1 | 0 | +| Hoch | 1 | 4 | 3 | 8 | 0 | +| Mittel | 3 | 9 | 5 | 17 | 0 | +| Niedrig | 5 | 11 | 9 | 20 | 5 | +| Hinweis | 6 | 11 | 8 | 8 | 17 | -Nach beiden Audits sind **keine offenen kritischen, hohen oder mittleren Befunde** bekannt. Die schwersten Funde des +Nach drei Audits sind **keine offenen kritischen, hohen oder mittleren Befunde** bekannt. Das Dritt-Audit hat gezielt die +Fixes des Zweit-Audits angegriffen und dabei unter anderem eine Umgehung über XML-RPC `system.multicall` gefunden. Die schwersten Funde des Zweit-Audits betrafen nicht den OIDC-Kern (der hielt allen Angriffen stand), sondern die Ränder: Umgehung des Nur-Button-Modus über `xmlrpc.php`/REST, die Verknüpfung von Administrator-Konten über das frei setzbare `mail`-Attribut und Multisite-Rechte. @@ -256,7 +257,46 @@ Status: ✅ behoben (mit Regressionstest) · 📄 akzeptiert/dokumentiert - **Deaktivierte Konten:** Passwort, XML-RPC (Passwort und Application Password), REST, bestehende Cookies, Microsoft-Callback, Super-Admin – alle abgewiesen; keine Selbst-Reaktivierung möglich. - **Graph-Client:** Paging-Links auf `https://graph.microsoft.com/v1.0/` festgelegt, 1000-Seiten-Limit, jeder Fehler bricht vor Änderungen ab. -## 7. Nicht im Umfang +## 7. Dritt-Audit 1.1.0 (24.09.2026) + +Drei unabhängige Prüfbereiche: (1) Review aller Fixes des Zweit-Audits auf Vollständigkeit, Umgehbarkeit und neue Fehler, +(2) frischer Penetrationstest der Anmeldewege, (3) Sync, Admin-Oberfläche, Datenhaltung und Datenschutz. Jeder Befund per +Proof of Concept bestätigt (HTTP gegen `xmlrpc.php`/`wp-login.php`, signierte Test-Tokens, simulierte Graph-API, in eine +Multisite umgewandelte Testinstanz); jeder Fix mit Regressionstest. + +| ID | Schwere | Befund | Status und Fix | +| --- | --- | --- | --- | +| D-1 | Hoch | **Nur-Button-Modus über XML-RPC `system.multicall`:** Die Ausnahme für Application Passwords nutzte `did_action()` (anfrageweit). Aufruf 1 mit einem beliebigen eigenen Application Password, Aufruf 2 mit Admin-Name und normalem Passwort → Admin-Zugriff. | ✅ Ausnahme nur für genau den Benutzer, den das Application Password im selben Anmeldedurchlauf authentifiziert hat (Reset bei Priorität 0). Per HTTP nachgetestet. | +| D-2 | Hoch | Admin-Schutzregel griff nicht bei ausgeschalteter Objekt-ID-Bindung (`bind_oid`) bzw. bei bereits gespeicherter, aber nicht geprüfter ID. | ✅ Privilegierte Konten gelten nur bei aktiver Bindung und passender ID als verknüpft, sonst gilt immer die Regel. | +| D-3 | Hoch (Multisite) | „Privilegiert“ wurde nur auf der aktuellen Site geprüft: Admin von Site B über Site A übernehmbar. | ✅ Rechte auf allen Sites des Benutzers zählen. | +| D-4 | Mittel | Liste privilegierter Rechte zu eng (Redakteure mit `unfiltered_html`, Plugin-/Theme-/Benutzerrechte fehlten). | ✅ Erweitert; Filter `m365_login_is_privileged_user`. | +| D-5 | Mittel | Deaktivierte Admins (ohne Rolle) galten als nicht privilegiert → verknüpfbar, bei Reaktivierung wieder Admin. | ✅ Gemerkte Rollen deaktivierter Konten zählen mit. | +| D-6 | Mittel | Cookie-Sperre (Z-3/Z-6) wirkungslos unter WordPress 6.0/6.1 (Filter-Argumente erst ab 6.2). | ✅ Ohne Benutzer-ID-Argument wird in API-Kontexten immer gesperrt. | +| D-7 | Mittel | Sicherheitsstopp durch im selben Lauf angelegte Konten verwässert; Testlauf und echter Lauf entschieden unterschiedlich. | ✅ Quote aus den vor dem Lauf verknüpften Konten. | +| D-8 | Mittel | Rollen-Entzug ohne Sicherheitsstopp: eine geleerte Gruppe stufte alle zugeordneten Admins herab. | ✅ Eigener Stopp für den Entzug administrativer Rollen (max. 20 %, nie alle; Filter `m365_login_sync_demotion_limit`). | +| D-9 | Niedrig | Lauf-Sperre per `add_option` nicht atomar, lief bei langen Läufen ab. | ✅ `INSERT IGNORE`, Übernahme per bedingtem `UPDATE`, Auffrischung alle 250 Benutzer. | +| D-10 | Niedrig | Tenant-Schutz verzögerte nur um einen Lauf und griff beim ersten Sync nicht. | ✅ Tenant wird pro Konto gespeichert; deprovisioniert wird nur im eigenen Tenant; Alt-Verknüpfungen ohne Tenant, die nicht gefunden werden, bleiben unangetastet (Warnung). | +| D-11 | Niedrig | Doppeltes Deaktivieren überschrieb gemerkte Rollen; Zeilenaktions-Nonce nicht an den Zustand gebunden. | ✅ Deaktivieren idempotent; Nonce pro Zustand. | +| D-12 | Niedrig | Nur-Button-Modus mit kaputter Verbindung zeigte Passwortfelder, die nichts bewirkten. | ✅ Felder ausgeblendet, Hinweis „vorübergehend nicht verfügbar“. | +| D-13 | Niedrig | Altdaten aus 1.0: deaktivierte Konten nicht gehärtet, gespeichertes Key+Cert-Bündel. | ✅ Einmalige Migration; `.cer`-Download immer neu exportiert. | +| D-14 | Niedrig | Speicher bei sehr großen Tenants; ein Fatal Error hinterließ Sperre und keinen Bericht. | ✅ Laufzeit-Cache wird regelmäßig geleert, nur IDs gehalten; Shutdown-Handler meldet den Abbruch und gibt die Sperre frei. 📄 Für >20 000 Benutzer WP-CLI empfohlen. | +| D-15 | Niedrig | Profilbilder nur am Header geprüft (Polyglot, Dekompressionsbombe, EXIF). | ✅ Neu kodiert (240 px JPEG/PNG), max. 4096 px, Download-Limit 2 MB, `index.php` im Ordner, Löschung bei Deaktivierung. | +| D-16 | Niedrig | Keine Export-/Lösch-Werkzeuge (DSGVO). | ✅ Exporter und Eraser registriert. | +| D-17 | Niedrig | Rechte-Proxy mit mehreren Stufen (CDN → Load Balancer): rechter XFF-Eintrag ist der innere Proxy. | 📄 Einwertige Header wie `HTTP_CF_CONNECTING_IP` verwenden (Doku). | +| D-18 | Hinweis | Nicht-privilegiertes Konto, später befördert, bleibt mit seiner Verknüpfung. | 📄 Vertrauensmodell: Verknüpfung prüft beim Verknüpfen. | +| D-19 | Hinweis | Graph-App-Token 50 Min. im Klartext-Transient. | 📄 Wie Core-Transients; Datenbank schützen. | +| D-20 | Hinweis | Avatar-Auflösung per E-Mail-Adresse. | 📄 Bewusst (Kompatibilität mit `get_avatar( $email )`). | +| D-21 | Hinweis | Benutzernamen aus dem lokalen Teil der E-Mail, Anzeigenamen aus Graph. | 📄 Nur kosmetisch. | +| D-22 | Hinweis | Admins mit UPN ≠ E-Mail waren gar nicht mehr verknüpfbar. | ✅ Neu: „Mit Microsoft-Konto verknüpfen“ im Profil und zugewiesener UPN pro Benutzer; Anmeldung findet gebundene Konten über die Objekt-ID. | +| D-23…25 | Hinweis | Kleinere Robustheitspunkte (Notices bei Array-Eingaben, Autoload der Einstellungen bei Netzwerk-Aktivierung, `fields => array('ID')` durch Abfrage-Cache als String geliefert). | ✅ Behoben. | + +**Neue Funktion aus Sicherheitssicht (D-22):** Die Profil-Verknüpfung verlangt eine gültige WordPress-Session, einen +Nonce, denselben Browser (State-Cookie) und dieselbe angemeldete Person beim Callback; eine bestehende Verknüpfung kann +nur ein Administrator aufheben, eine bereits anderweitig gebundene Objekt-ID wird abgelehnt. Restrisiko: Wer eine gültige +WordPress-Session eines noch nicht verknüpften Kontos stiehlt, kann dieses mit seinem Microsoft-Konto verknüpfen – wie +bei jeder Selbstverwaltung eines zweiten Faktors. + +## 8. Nicht im Umfang Sicherheit der Microsoft-Seite (Entra ID, Graph), WordPress-Core, Hosting-Umgebung, andere Plugins/Themes, Schwachstellen in PHP/OpenSSL. diff --git a/includes/class-m365-login-auth.php b/includes/class-m365-login-auth.php index 76f0105..c82d9b9 100644 --- a/includes/class-m365-login-auth.php +++ b/includes/class-m365-login-auth.php @@ -21,6 +21,8 @@ class M365_Login_Auth { const META_OID = '_m365_login_oid'; const META_LAST_LOGIN = '_m365_login_last_login'; const META_TID = '_m365_login_tid'; // Tenant the object ID belongs to. + const META_UPN = '_m365_login_upn'; // Microsoft account (UPN) assigned by an administrator. + const LINK_NONCE = 'm365_login_link'; const JWKS_CACHE_TTL = 12 * HOUR_IN_SECONDS; const HTTP_TIMEOUT = 15; @@ -316,6 +318,22 @@ class M365_Login_Auth { return $this->authority() . '/discovery/v2.0/keys'; } + /** + * URL that links the signed-in user's WordPress account to a Microsoft account. + * + * @return string + */ + public function link_url() { + return add_query_arg( + array( + 'action' => self::ACTION_START, + 'm365_link' => '1', + '_wpnonce' => wp_create_nonce( self::LINK_NONCE ), + ), + wp_login_url() + ); + } + /** * URL that starts the Microsoft login. * @@ -354,6 +372,18 @@ class M365_Login_Auth { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- redirect_to is validated with wp_validate_redirect() before use. $redirect_to = isset( $_GET['redirect_to'] ) ? wp_validate_redirect( esc_url_raw( wp_unslash( $_GET['redirect_to'] ) ), '' ) : ''; + // "Link my Microsoft account" from the profile: the signed-in user proves ownership of the + // WordPress account, the Microsoft sign-in proves ownership of the Microsoft account. + $link_user = 0; + if ( isset( $_GET['m365_link'] ) ) { + $link_nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : ''; + if ( ! is_user_logged_in() || ! wp_verify_nonce( $link_nonce, self::LINK_NONCE ) ) { + $this->fail( 'invalid_state' ); + } + $link_user = get_current_user_id(); + $redirect_to = admin_url( 'profile.php' ); + } + $state = M365_Login_JWT::b64url_encode( random_bytes( 32 ) ); $nonce = M365_Login_JWT::b64url_encode( random_bytes( 32 ) ); $code_verifier = M365_Login_JWT::b64url_encode( random_bytes( 64 ) ); @@ -369,6 +399,7 @@ class M365_Login_Auth { 'verifier' => $code_verifier, 'cookie' => hash( 'sha256', $cookie_token ), 'redirect_to' => $redirect_to, + 'link_user' => $link_user, 'created' => time(), ), self::STATE_TTL @@ -496,16 +527,29 @@ class M365_Login_Auth { $this->fail( 'external_identity' ); } - $email = $this->email_from_claims( $claims ); - if ( '' === $email ) { - $this->fail( 'no_email' ); + $oid = isset( $claims['oid'] ) && is_string( $claims['oid'] ) ? strtolower( $claims['oid'] ) : ''; + + if ( ! empty( $attempt['link_user'] ) ) { + $this->link_account( (int) $attempt['link_user'], $claims, $oid ); } - if ( ! $this->domain_allowed( $email ) ) { + $email = $this->email_from_claims( $claims ); + if ( '' !== $email && ! $this->domain_allowed( $email ) ) { $this->fail( 'domain_not_allowed' ); } - $user = get_user_by( 'email', $email ); + // 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. + $user = $this->find_bound_user( $oid ); + if ( ! $user ) { + $user = $this->find_assigned_user( $claims ); + } + if ( ! $user ) { + if ( '' === $email ) { + $this->fail( 'no_email' ); + } + $user = get_user_by( 'email', $email ); + } if ( ! $user instanceof WP_User ) { /** This action is documented in wp-includes/user.php */ do_action( 'wp_login_failed', $email, new WP_Error( 'm365_login_no_user', 'No WordPress user with this e-mail address.' ) ); @@ -520,8 +564,6 @@ class M365_Login_Auth { $this->fail( 'account_disabled' ); } - $oid = isset( $claims['oid'] ) && is_string( $claims['oid'] ) ? strtolower( $claims['oid'] ) : ''; - // Entra group restriction. $group_check = $this->check_groups( $claims, $oid ); if ( true !== $group_check ) { @@ -767,11 +809,155 @@ class M365_Login_Auth { * @return bool */ private function may_claim_privileged( $claims, $user ) { - $upn = ! empty( $claims['preferred_username'] ) && is_string( $claims['preferred_username'] ) ? strtolower( trim( $claims['preferred_username'] ) ) : ''; - return '' !== $upn - && ! $this->settings->is_multi_tenant() - && ! $this->is_external_identity( $claims ) - && hash_equals( strtolower( $user->user_email ), $upn ); + $upn = $this->claimed_upn( $claims ); + if ( '' === $upn || $this->settings->is_multi_tenant() ) { + return false; + } + $assigned = strtolower( (string) get_user_meta( $user->ID, self::META_UPN, true ) ); + return hash_equals( strtolower( $user->user_email ), $upn ) || ( '' !== $assigned && hash_equals( $assigned, $upn ) ); + } + + /** + * User principal name of a member account from the token ('' for guests/external identities). + * + * @param array $claims Verified claims. + * @return string + */ + private function claimed_upn( $claims ) { + if ( $this->is_external_identity( $claims ) || empty( $claims['preferred_username'] ) || ! is_string( $claims['preferred_username'] ) ) { + return ''; + } + return strtolower( trim( $claims['preferred_username'] ) ); + } + + /** + * Account bound to a Microsoft object ID (network-wide), if any. + * + * @param string $oid Object ID. + * @return WP_User|null + */ + private function find_bound_user( $oid ) { + if ( ! $this->settings->get( 'bind_oid' ) || ! M365_Login_Settings::is_guid( $oid ) ) { + return null; + } + $ids = get_users( + array( + 'meta_key' => self::META_OID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + 'meta_value' => $oid, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value + 'fields' => 'ID', + 'number' => 2, + 'blog_id' => 0, + ) + ); + if ( 1 !== count( $ids ) ) { + return null; // None, or ambiguous (bound twice by an older version): fall back to the other rules. + } + $user = get_userdata( (int) $ids[0] ); + return $user ? $user : null; + } + + /** + * Account whose administrator-assigned Microsoft account matches the token's user principal name. + * + * @param array $claims Verified claims. + * @return WP_User|null + */ + private function find_assigned_user( $claims ) { + $upn = $this->claimed_upn( $claims ); + if ( '' === $upn || $this->settings->is_multi_tenant() ) { + return null; + } + $ids = get_users( + array( + 'meta_key' => self::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; + } + + /** + * Binds the Microsoft identity to the signed-in WordPress user who started "Link my Microsoft account". + * + * @param int $user_id User who started the link. + * @param array $claims Verified claims. + * @param string $oid Object ID. + */ + private function link_account( $user_id, $claims, $oid ) { + // The browser must still be signed in as the user who started the link. + if ( ! $user_id || get_current_user_id() !== $user_id ) { + $this->fail_link( 'link_session' ); + } + if ( ! M365_Login_Settings::is_guid( $oid ) ) { + $this->fail_link( 'invalid_token' ); + } + if ( M365_Login_Sync::disabled_info( $user_id ) ) { + $this->fail_link( 'account_disabled' ); + } + $stored = strtolower( (string) get_user_meta( $user_id, self::META_OID, true ) ); + if ( '' !== $stored && ! hash_equals( $stored, $oid ) ) { + $this->fail_link( 'link_other' ); // Unlinking is an administrator decision. + } + $taken = get_users( + array( + 'meta_key' => self::META_OID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + 'meta_value' => $oid, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value + 'exclude' => array( $user_id ), + 'fields' => 'ID', + 'number' => 1, + 'blog_id' => 0, + ) + ); + if ( ! empty( $taken ) ) { + $this->fail_link( 'oid_mismatch' ); + } + + update_user_meta( $user_id, self::META_OID, $oid ); + if ( isset( $claims['tid'] ) && M365_Login_Settings::is_guid( (string) $claims['tid'] ) ) { + update_user_meta( $user_id, self::META_TID, strtolower( (string) $claims['tid'] ) ); + } + $this->log( sprintf( 'User #%d linked a Microsoft account from the profile.', $user_id ) ); + + /** + * Fires after a user linked a Microsoft account from the profile screen. + * + * @param int $user_id User ID. + * @param array $claims Verified claims. + */ + do_action( 'm365_login_account_linked', $user_id, $claims ); + + wp_safe_redirect( add_query_arg( 'm365_linked', '1', admin_url( 'profile.php' ) ) ); + exit; + } + + /** + * Ends a failed profile link with a message on the profile screen. + * + * @param string $code Error code. + */ + private function fail_link( $code ) { + $this->clear_state_cookie(); + nocache_headers(); + wp_safe_redirect( add_query_arg( 'm365_link_error', rawurlencode( $code ), admin_url( 'profile.php' ) ) ); + exit; + } + + /** + * Translated message for a login/link error code. + * + * @param string $code Code. + * @return string + */ + public function error_message( $code ) { + $messages = $this->error_messages(); + return isset( $messages[ $code ] ) ? $messages[ $code ] : $messages['provider_error']; } /** @@ -1081,7 +1267,9 @@ class M365_Login_Auth { 'fallback_locked' => __( 'Too many attempts. Please wait 15 minutes.', 'm365-login' ), 'too_many_attempts' => __( 'Too many sign-in attempts from your connection. Please wait a few minutes and try again.', 'm365-login' ), 'account_disabled' => __( 'This account has been deactivated.', 'm365-login' ), - 'privileged_unlinked' => __( 'For security reasons this administrator account can only be linked to a Microsoft account whose user principal name equals the WordPress e-mail address. Please contact an administrator.', 'm365-login' ), + 'privileged_unlinked' => __( 'For security reasons this administrator account is not linked automatically. Sign in once with your password and click "Link Microsoft account" on your profile page – or ask an administrator to enter your Microsoft account (user principal name) in your WordPress profile.', 'm365-login' ), + 'link_session' => __( 'The link could not be completed because you are no longer signed in to WordPress. Please sign in and try again.', 'm365-login' ), + 'link_other' => __( 'Your WordPress account is already linked to a different Microsoft account. An administrator can remove the link in your profile.', 'm365-login' ), 'external_identity' => __( 'Guest and external accounts cannot sign in here.', 'm365-login' ), ); } diff --git a/includes/class-m365-login-sync.php b/includes/class-m365-login-sync.php index cfc7f9e..258ce87 100644 --- a/includes/class-m365-login-sync.php +++ b/includes/class-m365-login-sync.php @@ -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( '
%s
%s
| + | + + + + + + + + + + | +
|---|---|
| + | + + + + + + | +
| @@ -2252,7 +2341,36 @@ class M365_Login_Sync { |