From 81b3a74ae5cd4386f2b2dc45f2aca5935c4088e1 Mon Sep 17 00:00:00 2001 From: Friederich Loheide Date: Thu, 24 Sep 2026 03:57:45 +0000 Subject: [PATCH] Let administrators link Microsoft accounts whose UPN differs from mail 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) --- CHANGELOG.md | 5 + README.md | 30 +- docs/security-audit.md | 60 ++- includes/class-m365-login-auth.php | 214 +++++++- includes/class-m365-login-sync.php | 138 +++++- languages/m365-login-de_DE.mo | Bin 58444 -> 62619 bytes languages/m365-login-de_DE.po | 710 +++++++++++++++------------ languages/m365-login-de_DE_formal.mo | Bin 58541 -> 62736 bytes languages/m365-login-de_DE_formal.po | 710 +++++++++++++++------------ languages/m365-login.pot | 704 ++++++++++++++------------ readme.txt | 5 + uninstall.php | 2 +- 12 files changed, 1583 insertions(+), 995 deletions(-) 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

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

%s

', 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 ); ?>

+ + + + + + + + + + $value ) : ?> @@ -2252,7 +2341,36 @@ class M365_Login_Sync { -

+ +

+ -W9qCJ^Z#eSSVZe(t$*?^)jS zo^x(|_2q;upC&|)bxpiY@z33@lPm4xdhjr2;mAHp^}}D_0IYM% zTTp)AhuL@>rGbv=O7*~<*nm%C|5hrhYH4*cH}0Ue6g+}y_z}*+A8-`TxIlL>ii0U{ z#WHNfBbeP^siBx`DD^WOfwZlHt~cRu%J-wZ_cq$JuP&wg)41^{zKQ?F?)Z9!QaqzR z#EUR#Afv!>n1Iu8JkCOC_y(j8>M^7W^#;;+^)+6KT?f%AoQpDm>oA}8)o-|vk)J{t z`FA+4l~SEDbqBmWy_)-LP@YS=P}>=0YSM8P=Hcu35Xt~nG7ls1R!qi5H1QD1K!y)d zDk?urA@F@CGz+^DyGyo{Km)WUijW4tNx0Do!H#salgc@?w9KEFOvsLQO(>KHyr1 z^4waK2yH^?*dr*p@i_h*-$H3OGfGC>!NmwH!h^UT`!I0^?n0^HW$cQFaR8n`nYtu0 zlc=i>$U;zKuq`e`>Bv&-LL@4166IAaC3$W?N{&ST#zikKPUFRxGMdc9e3X#AfG^=I zNT*edhYIijN{HKy(H)sPR!8s(Jjm}gNbuC8afBVKQKs%o+>GBL5s9jsSVPj|-{3@y zqaVM*t8mE#{d)t-?=PU_M$#op4Z=>?6Gx%cGZUrZ`6$n=aovb4D0L@ZhR@=2vi_6V zEu?}MQ9^nYWlldtdZIC>?zsJK^g% z7~eywH+d?11opu>I31$~7k6?Y59~u3`D@7Zs3R!J*S%1w1_n1 zrr+C!1VKHFz40@YfhA2>iu6$iN;?ZtB2zJ)_%jV^B{!tvr!W&gM+tS08G4x**p6}z zK8qKlOw~8|1fIbF#%Jms&X}c_?OK!$-iH^VF z#11UhJMnEiiNPYJw&3EqdO3ZIms1{7Oyc1!NRZUK*b_76>GC+Np}ZQi@H_l98qxWB z#EmEs_!N7|4-0hY#-VH!^RYiJ!wYaVcEoL%ihJDq4OmY3U0lkP6)e=J>rQ$p&wY&5 z_zl)!wDbxc>i1C^`V6nf@2~@2y+|qkRX6dU#rPpg=qFN;jufJd$iyYM5qse&l#V3* zLZ2BHuBQA4ln76`(%E04YBv|s&>rlA&)`~o8@n-rCDhs)mtUpSkK7M4aq@g<83zci zM(I!k$_N`#e*Y7;#bYRu`WR_jeT$jYSL~5h!u+ow0p!7T*bg_MWanO#q*vU>q1 z;%g`!Of4lZuqRHzF(^~E5>xO7x4ap1DBp|O_zDie?@%H(piG}3OE4-AM7dzP)k8Q6 z6U*7va2yW8U6_Zj;%xjLrK0J6{Y7&2O>gP2x9=kW%V=NAwbX>Su2Fa`T7 ziT_M)w5GKToP!0p9B;)Za5G+J>7+b@y(urF_7|`onGH3mS|{IqDDSsgt_M_z%!b;A zQh$Q2Ptn0hRcf3~{D*R}n;Q~=KVy5$3F-S|QRdc*U9sME14=};V=BIm67r)c9ZU)9 z+!=(|Q_jO%@OO9*7DSxvSI47VNQ0kXGJcKHq3`h_99*M6MBczpDWAlS_-3v41ZGkG z68mHN3Oywga0cZn+>KA+IP}z!`MAY3`cE!cX)3>7ug`mNB<21q^;{Ptx72oQgXybu zhXrf)rfcN3&DEmb08dePM!F=jpd99A<{_FHW+ppnxr*4(T#ZsJr zvvCU!z)x`$wkLylM@_N{z*fu>%HCreqafit8{JUqb2NHz><0?PmRK$z_wx|3wcEcBuN7NDg2+LVhGJrX^ z>M1BgNxr+0m8N!J8ur|z@8_Y^HyS%*zRYn77sc+4CHM?w3q3e=v(AC_ID+ysNVL^I zP(~1r>14VY6DaS%U*r9_8>bQXA$S`7n0}jH_tzr}Pu(2lq8%4Wx9g5{!nTzAxDG_= zaSr|x$6_KriQBOOYl+~JJ9P5Cb*Fy+6O<`Djgq`SqSTXqmo8t3sg$DyT=e3i*tHU+ zLu;@o z&%%5x#k*wv@8&|1X7YBWzQE}?9>?#{Upx_9O~r>%Mm+aE{;cCp8(Z(zQ;>%j zQ=W+OTn);cM==N2<1E~VCouUzJyj=BI+F4mo|O>x;6m2xKJWAya@HO_RVzt=KHR?%yW`Hi#Gk}e`?zr-7XMa1 zP>+vMz71(ZO?y=D zU%oq4fw$7*f8Y$tzkFQ(^?DG=KQ;3Sz05vA3GMed5T`woGS^#4b=3o9rO-ml$+vbxUTQjB(emJ^E$8zs~w&*{(Xj?XhKlv97tcN@NhRoL$j zdU@T9a{mjIIW26|f3AD*F3Qj2a9n&q|Nn!VQI_+(7j#Ztivwt19pa)dKb%6DlXfra zTL2y~ zvKD2EH()y6gOAd_+Q-FkMo{@W>l3#grebVh!ey@BeM5impT=a$qyMZ&cnM1G%tVRI zmDmN#@H(u+@znDJ%5od~mR^RlQ6l~ejLO`t;6g$YLs^FRxDUL5qbVOp>3J_QvKwY0 zgH@McIkx+YzHg&+a4n9*4LBBGLRk&!9sUm(T!3xx&3A}@Di?p_Mn0az9+>l2{XH-l z3n`l@&pnJX!lzL>{3?#cw{Zk^I-_D#9{a%N{7G23AprK;=h86E$`|* zy8GXBmQKJW+^<0C*vmK$Q;zEFEx^H)*W)IPtQ9AC!0N#!bnDDvY0iQ<+@%LDWbH1R{cpJ);oxw{arW;S|t?(6G z%MaOK>f`h=Y)|ia2`s%%P>Xe zG{A+tP>T}ED0aaOC@*YteHdlV_F*?Xg!0@m?22Ew_Y=s(?v&F|BASE6I1N?ih(Vzo zBXgwl--`yt8+z?&T@dhFm8Q?|c)h`h6*fxDK+q}+8R4Mev4Y`p(>Ah#HrGbPsMA>HoE5(>A4P!)JQDVSi1m?V?T-n)^V%tIq8OJjz zv}*5mD`*AkVk7!ZPG>inWK`S1Qh&hAF?go7+_dCDquR7X zLCYCkNQPf(2dnfC`sw(Fe*WY(ZcS2cQr3c^IYZ)K4@^sqI50@Kt!H8{C)2-T|%^VVPZ@}-ZG%{w%Ow9Is?O-Ta%3SH$&EQq{ z9o-k+Ei;Ydt79?(PpGo5Oti;W<+uEyut@-^;dfW zhUKX;he)TKu3Szxifl8~9GZA+L{Yo0=l3LjZ2acL4q3A=x_GoPD_G{YhQvJ+6I&S5@J1 zPm630mlIp*ypCfoU6yz`!wJ<{UMJ>;xq@s8)KLphH?0YYkIq}8RN{1Qd_qioFcd4A z(lguVFD+&6nIV{wS zJY!0^NsdT^Wp*%9Z5&^_*|38F)2Q-PSMy5j#zpCwB(}_Y6*IInATzX7qO{bh$se?q z2K-h2aQvT(9!iPI2-rOWK4(Fem=vn2111f!roA3QEZOms^>=n9G5F><GQP^hjmU;YEo|;dme;Tn^vfVK%&v}gMAnU4xk+{18Yln=lmA#_Utd@Mx-C^u? zM#%I=_(9^}&NOL6!7^8b&&dmKu-b2icsgKuWk~L#H%k3xz(x9-zWs;n1tq%Ixm7TH5zDPH{^oYtOG~78S8OZV21b>&LJfkpZ$4-=G z$FK8W-8RVydhB#Yw;r;?Owg6rdTd51EyQ+YcN@m2N;oK(6iHc@*m*gdW0*B;h0fj| zip`zUqf3j0A^SvZu$>j_ZvQcMi=Ec5*4^#sjO>)0G43v9cs*9EA!ks+qF^L;I=y>} zEXjavdVF=UA1a1kl>gJMisucRfUz`W9-KUB+L`pM#gEt}Z5Gs+cBR$WP)+PY#>}9_ zP|D2m#wYBsQ6Di(zs2D}(8wB#b%o4NiCO8f*atcP=IirMf>0Z=eI$(ETON*G)0oa7 zxUi}22urWT^c#~+pT{mWYRJQBPLf7S%gqv#T+Z|v=k&zs2o36N?TrQ?hI-2 zKXxW|WqnTEQ=i;AR>dk@=iH;M5RK%YyDz()bE08*+;cSKEpO5CY*sLP1$~!Uui`jkd9imm8BnB;Rr%0i z{(VNJoUEqoNlg#f!H(>b)LzOFdhcw1IQDF;Pt>%IuimQ9REb83KO8olq_SG}FvqFq z{J~8&tw^}uwB?Mgmov8ch;`d--Yj$7>7l>%5sqsKpNh}@Rbl%Cw==CxD}MX74N0j+ zi=(#3{fk>C>j=fnT{}{8e_m2yR)%w@7sSr&I^2BX=ITu6cKp!pkx40Mvo-$BBQx5N z?F9_*zxJ20oi}fMd;0iZB(I$bt7?9;Y4x$MSyfDlEFFn>Q_Z>aqw_a)p-*~8nCeAUp%)rF=jP(k3D*uNm0|d%#LtYSh>YPUbk`=NBGvV2vw}lfsD+; z|LL^8>A;WeJDfY1*uzINqB*Qs(%2W#dk1GuIcdw*)tqQ7fihEO#^ea)%P`NF>}0ib zORpbykucs+#LMQ(^qaP2M#6cjfX<)WsJqCTA{gZZ)m^PJ>9Y8o6C@ll87L{{spR{f zAWB*|0kh~`xrfgaCe`VBh!!NZbRu|4Ld+kn>-@ADcY4c1tih(4*Xh*Qz^)Uv8+R@! zV~IyX>~M`c%c<1e^h#y8wmXO%gU@JcbSA>x*7Vj=!;$5rr&EO$tg0f%2vPjn!~K(@ zRN(xm=b2O9<~R*Y6it%@ChVN`xrJtVz~r!>S1pNW+PQikU2OWpO$WY=jEa38%fn{n zM#1d?X=5{8RGXt`uqm&6vFjRpkL6sI<-n(2<2Gj^>ZKJv@cGJO+p4X0zVULMZ#?Jw zM7A&HF~0HbqwPjTNCyJJmVNFvDcR9{4v-xantH~_4PPwpou0!<2zOs@e$bueGP8t< zW;C*5J!Fe>V#Gd(T`)jSNoSX{jj6e_`C!h?cfObTLer}9Y{9P0%@js37peR8xn_bhhmq#Gc129 e54AXWoR*wCX_50Sjt%$JaJ~cLKl`9l!hZmYHSK%= delta 7169 zcmYM&30#!b`oQtSs@OOL!H!zw$ zcns-V1+>++!A#;Tl=}+NiJ#(7p05TbD0Q9#E3pMy45j#AmGh@7)?i~ifkAi)v+)Yb zgIh6uZdHR&GGXP9WM&EW#aFQlevLAL=tQM*us4QzXjIXVnJ>e!0ZMJf`NVgUbY{wv zwF^*|VkOSQ&3FdewATaw5g#QE;3=Wl4NdHYGJ&oB<2x~&_+9iw(KzNma2}fyU&Ctr z1;^sDj!Ll_s^0HCEF_L*p=Cf-$nvTeFc{xNN%2m64wE`56^V5?9uHsxjCzFpKSd*! zJT}5I3_}mf1%+LdGi&7IEa1;)~ zaID4}d>Kb$wx=`eNn-^X7)z#01s?w=^deFxdWDvUzuj-DhM3u#P3N!bX|z{jwk?EjNAY#fN_q0|&CN4em0laFy>u!Q zF^4!ErK1w8#P#@hxvsZTi|{N);e&U(|0|FDbB!88{uq>6i&9({{qav&PMkGLf2zHO zFB4xtnPJ6fJ>xAHE5}hX^%I6;_!wPVG1!(k3mfAEe{6ba6mnn@PGxOwAXOwx(?&ng4h)&;VP6=e}^)F+xRp#o}dTpvD1(NEJmK8 z{)O{#0Qr$r9>Wk!`5*m&43ru6!X-EXn=|lwd_ep&PnUuG#M9X*>ORTW^~% zacn62e=-e8i52-@x%ktb4$q)e`<1_Q=}i({{amdVG}A!ZfuFX{8TQs#tM81t5F8p^%=dL24Fwp;mAzXax~D3 z193OX0Pdo^nu9GmZh~pVtufU@Vv#`fqKltC>Mo?Q&8UB7cm^K`{P>}OMC}qhK=2Nz$tz^`|XYmIo}7{;}8r)yZ^kW zgobonj*_Bh{jNlLa5Y9@t^fGj{^R@o$B$qO&Y$zg-(nNuyZ+-1%k=&67|rnv9Dsvx zcL3|ZltwcS^q!?Z14f~&JYr|0&p&cnC_yD)1HDfwKR{%u(tj+EFsu zlQhZ%mtqonaVoxxd9wdw=IZTcVmG-E7ve$Ojs5582j0Pv#GNX2>dR3ExEeFD4p}`_ zkLz%Bm42h%z`Mjfc~&ewI$yshMqm$)DLah_+=epJUDy}j$8@}p?J#wL-ZptCGbzQP zxDGpGJ<32LpViyAFG_#QQFcu=Is?cQZY4GrvH!zp9Hk)_p2FsM5m(_Yyn%DsGct0^ z5`W53cE=86nbmHL!A?u{@f?)?hN9HYIF#p@{~K8W@PvumI&n^a;L=7jXhEdqKaru3-xC z%C)+`Jt!IV9Ht@L?Vl(egn0EpT3|f!V3Zl;V*{Lt7jO<*aSWv{_wU8#cmrGEJy>&D$D`EDZTty;!)*Ed|F&9xQ%S7hHA#vqQD*#L1FvIj#NAi13ua-{|5b|r)iD0Z z5`BX{yonuf(?)&&2RMZI4Ax@Hm-H8%Jt(PfyGec*u>L7DB&DVJ7|y{|d>fFZh|$y2E)8T}=2jLeeDUE-f?+Fa3BMv9t_lEv07{n~d zx>}6QFtJW&?6ErXpTvP@InWOOhQ07O#$)gf{bET(=`b5d;$oEh&S4Y$4P^kKZ&An? zg(3JYp2b^Ofrr={b1{3Tp2*&vm10&rSIsU<417@@hI{o9>8d9PyVN3FT9N# zQI=@MZvC@6YmfdHj-RoZ)3T6Bq5%Kfm{2265$n zsZu^%P%`vyY=bQi=q2ci9f|v6Di->WufrJPtyqQoQC?s@-qXJocOKMB?fH{N1s62_ zyZ$x13?n3dU#GM+vizz&%95PIS@;85F#nLg?jI;iat3AQzhYao9A;bNe0&B2j_99a z({Q%z{|7WeIWg;~Udt+MP5eBv3hE8a4B+2=j?p2m`amy5-9Pl#?4v06hkmGM6p6BJ z+h7=`V^i#b3voDR)6Z#aA^X44alL)oqNFwr<8T;CQCU$^?C_tj#>a?#D8+XZo8vt^ ziIE@c7tJM<;{!j@1DuFGiKk!>+<@^sU!A2vy{OyR0Jojc1KNQ(#P4A=-p532&Qpe9 zcZ|eJlo_r-8So~Q2fT(|@ESgf%}(iUJs4%86)6Ayq-tms;zuakFZ1-nwNZBLKzswG z7$eT;@AbV<2KFM#j4op?Mt`cWe;S7oFT&n<8t0Mn@U!~E>bZ0J8`Tfz$o~^uknoxQ zm~|q{teT(aU4R#`9aeqLH(lIKnR_2=@I0oX z{i6OMvf(1VMRDK{4oIpZFX=Vwhs}t`Vk}O_EcBps_%8OqQz)qny{wla3AYn>!PoH; z%7Yhush74*z5aMl#!!xDdT1om$U-Kop2F7H@(VEhv!F!*cTZ&Q@(<58|p!AL2mOd3r&&==)~(SDysS(+Ir+h{Jz zb*oX9W~=}BZfrq(93^x0C^dBty_Fur_hqZvz~C~M>GU3sZ|&P3UlY*O<~D3Dqr_pK zW7utuBGc-foG>6RrPwTRa3)Ix;-p;?Orv zGrei;6GCPcmbgr>BcV-@yU_IZ8x!ZfJ~PobuiN8|eAoM|3id4A@p{eaXYehWe(Bc{14d@%Yz*eJ@VB5#(Dibz_)sQc-enk=t}w9ByOSAS1uf;wUh! zhRf#2Hw}6>>=u{XaLsY#XBatpj4uE94V&ZP6(x47;qSrXG?{?gn9f|R|2^+8XEAy+ z&2W_%4)39-le6?0vf(UqWEfAB>3-xPhNZwF-If@Z(o!;IH7)sWTe-z;D{;7deM>7F zdaZN5@+Hrm)WCOmVQOHY>GFQRczg%b;dYfeZTW?6BPGw~F0k87tE;HQ;VwxticEUU zWg4A)Z!PKFz!&-a%qB@i7Kg*I+Dw<}GIFfNHiymSc3Rve&h!y-m*FULdaGA4VerEH-<3j#VPl_-h2-j=ofHqs^^-hiX&9ePM5` z4Dr45_Q*hQueJ*D z*i2*G!!=1~O$se608=wuCN*R=iw(Q!t{~&aSjkv{dF)jOLvv>s!-@@yT~6qmjdIgj z