From 1708bae91a686e7154fce83b5a1e8d2163a4540f Mon Sep 17 00:00:00 2001 From: Friederich Loheide Date: Wed, 23 Sep 2026 16:34:19 +0000 Subject: [PATCH 1/2] Fix certificate storage being undone by the settings sanitiser store_certificate() and remove_certificate() wrote the option with update_option(), which runs the registered sanitize() callback in the admin (including admin-ajax). sanitize() expects raw form input, so it restored the previous certificate fields and encrypted the stored client secret a second time: "Generate certificate" did not keep the new certificate and broke an existing client secret. Internal writes now bypass the form sanitiser. Co-Authored-By: Claude Opus 5.5 (1M context) --- includes/class-m365-login-settings.php | 30 +++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/includes/class-m365-login-settings.php b/includes/class-m365-login-settings.php index 8fcbfaf..a4b8bd6 100644 --- a/includes/class-m365-login-settings.php +++ b/includes/class-m365-login-settings.php @@ -19,6 +19,13 @@ class M365_Login_Settings { */ private $cache = null; + /** + * Set while the plugin writes already sanitised values (skips the form sanitiser). + * + * @var bool + */ + private $raw_write = false; + /** * Default settings. * @@ -199,8 +206,7 @@ class M365_Login_Settings { $all = $this->all(); $all['cert_private_key'] = $enc; $all['cert_certificate'] = $pair['certificate']; - update_option( M365_LOGIN_OPTION, $all ); - $this->cache = null; + $this->write( $all ); return true; } @@ -211,8 +217,22 @@ class M365_Login_Settings { $all = $this->all(); $all['cert_private_key'] = ''; $all['cert_certificate'] = ''; + $this->write( $all ); + } + + /** + * Stores already sanitised settings. + * + * The option is registered with sanitize() as callback, which expects raw form input + * (it would, for example, encrypt the stored client secret a second time). + * + * @param array $all Complete settings. + */ + private function write( $all ) { + $this->raw_write = true; update_option( M365_LOGIN_OPTION, $all ); - $this->cache = null; + $this->raw_write = false; + $this->cache = null; } /** @@ -379,6 +399,10 @@ class M365_Login_Settings { * @return array */ public function sanitize( $input ) { + if ( $this->raw_write ) { + return $input; + } + $defaults = $this->defaults(); $current = $this->all(); $input = is_array( $input ) ? $input : array(); From 4edf20bc45c8aab55f8d81ac505f6a76fc47c641 Mon Sep 17 00:00:00 2001 From: Friederich Loheide Date: Wed, 23 Sep 2026 16:34:30 +0000 Subject: [PATCH 2/2] Add Microsoft 365 user sync with roles, profile fields and deprovisioning New "User sync" tab that imports Microsoft 365 / Entra ID users as WordPress accounts and keeps them up to date: - Scope: whole tenant or the (nested) members of selected groups, guests optional, e-mail domain allow-list respected. Existing accounts are linked by e-mail address. - Roles: selectable default role plus a group -> role mapping (in addition to or instead of the default role, first match wins). Roles of pre-existing accounts are only managed on request. - Profile: selectable Graph attributes (names, job title, department, phones, address, language, ...) and the profile photo as avatar. - Deprovisioning: accounts disabled or deleted in Microsoft 365 (or removed from the sync groups) are deactivated or deleted; accounts deactivated by the sync are reactivated automatically. Deactivated accounts lose every sign-in path and all sessions. - Safeguards: dry run, safety stop above 20 % (min. 5) deprovisioning, abort on any Graph error, "deleted" only on a 404 for the object ID, protected pre-existing administrators and own account, content reassignment required for deletion, run lock. - Runs manually, via WP-Cron or `wp m365-login sync [--dry-run]`. - Users screen column with deactivate/reactivate row actions and a read-only Microsoft 365 section on the profile screen. The Graph client gains paging, retry on throttling and user, group member and photo endpoints. The group picker is now reusable. Version 1.1.0, German translations (du/Sie), docs and audit addendum. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 19 + README.md | 134 +- assets/css/admin.css | 174 +++ assets/js/admin.js | 222 ++-- bin/make-pot.py | 4 +- docs/security-audit.md | 34 +- includes/class-m365-login-admin.php | 411 +++++- includes/class-m365-login-auth.php | 5 + includes/class-m365-login-graph.php | 234 +++- includes/class-m365-login-settings.php | 242 +++- includes/class-m365-login-sync.php | 1578 ++++++++++++++++++++++++ includes/class-m365-login.php | 10 +- languages/m365-login-de_DE.mo | Bin 33628 -> 51249 bytes languages/m365-login-de_DE.po | 1092 ++++++++++++---- languages/m365-login-de_DE_formal.mo | Bin 33637 -> 51316 bytes languages/m365-login-de_DE_formal.po | 1092 ++++++++++++---- languages/m365-login.pot | 1197 +++++++++++++----- m365-login.php | 8 +- readme.txt | 39 +- uninstall.php | 19 +- 20 files changed, 5532 insertions(+), 982 deletions(-) create mode 100644 includes/class-m365-login-sync.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 739ce8f..99887a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to this project are documented in this file. The format foll [Keep a Changelog](https://keepachangelog.com/) and the plugin adheres to [Semantic Versioning](https://semver.org/). +## [1.1.0] – 2026-09-23 + +### Added +- User sync (new "User sync" tab): imports Microsoft 365 / Entra ID users as WordPress accounts – the whole tenant or the (nested) members of selected groups, guests optional – and links existing accounts by e-mail address. +- Default role plus group → role mapping with a Graph-powered group picker; mapped roles either added to or replacing the default role (first match wins, reorderable). Roles of accounts that existed before the sync are only managed on request. +- Selectable profile attributes (display name, first/last name, job title, department, company, office, employee ID, phones, address, language) and the profile photo, which replaces the Gravatar. +- Deactivation or deletion of WordPress accounts whose Microsoft 365 account was disabled, deleted or removed from the sync groups; automatic reactivation. Deactivated accounts cannot sign in at all (Microsoft, password, application passwords) and lose all sessions. +- Safeguards: dry run, safety stop above 20 % deprovisioning (at least 5 accounts), abort on any Graph error, deletion only on a 404 for the object ID, protected pre-existing administrators and own account, content reassignment required for deletion, run lock. +- Scheduled sync via WP-Cron (hourly, twice daily, daily), `wp m365-login sync [--dry-run]`, report of the last run in the settings. +- "Microsoft 365" column and deactivate/reactivate row actions on the users screen; read-only Microsoft 365 section on the profile screen. +- Graph client: paging, retry on throttling (429/503/504), user, group member and photo endpoints. +- Filters and actions for the sync (`m365_login_sync_*`, `m365_login_user_disabled`, `m365_login_user_enabled`). + +### Changed +- The group picker is reusable (security groups, sync groups, role mapping). + +### Fixed +- "Generate certificate" and removing the certificate did not keep the change and encrypted a stored client secret a second time (internal settings writes ran through the form sanitiser). + ## [1.0.0] – 2026-09-22 ### Added diff --git a/README.md b/README.md index a071a9c..f0958d1 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ - [Gruppen-Beschränkung](#gruppen-beschränkung) - [Nur-Button-Modus & Fallback](#nur-button-modus--fallback) - [Eigene Login-Seite](#eigene-login-seite) + - [Benutzer-Sync](#benutzer-sync) - [Sicherheitskonzept](#sicherheitskonzept) - [Shortcode & Hooks](#shortcode--hooks) - [Fehlerbehebung](#fehlerbehebung) @@ -46,7 +47,8 @@ | | | | --- | --- | | 🔑 **Login per Microsoft** | Ein Klick auf der Anmeldeseite, Anmeldung bei Microsoft, zurück in WordPress – fertig. | -| 📧 **Zuordnung über die E-Mail-Adresse** | Es werden **keine Benutzer angelegt**. Nur wer schon ein WordPress-Konto mit derselben E-Mail hat, kommt rein. | +| 📧 **Zuordnung über die E-Mail-Adresse** | Der Login legt **keine Benutzer an**. Nur wer schon ein WordPress-Konto mit derselben E-Mail hat, kommt rein. | +| 🔄 **Benutzer-Sync (optional)** | Importiert Microsoft-365-Benutzer als WordPress-Konten – mit Standardrolle, zusätzlichen Rollen per Gruppen-Zuordnung, wählbaren Profilfeldern und Profilbild. In Microsoft 365 deaktivierte oder gelöschte Konten werden in WordPress deaktiviert oder gelöscht. | | 🎨 **Gestaltbarer Button** | Text, Icon (Microsoft-Logo oder eigenes Bild), Farben, Hover-Farbe, Rahmen, Eckenradius, Position – mit Live-Vorschau und Presets. | | 👥 **Entra-Gruppen** | Optional nur Mitglieder ausgewählter Gruppen zulassen. Gruppen werden direkt im Backend gesucht und ausgewählt. | | 🚪 **Nur-Button-Modus** | Passwortfelder ausblenden und Passwort-Logins sperren – mit geheimem Fallback-Link als Notausgang. | @@ -170,7 +172,19 @@ Benutzer liefert Microsoft keinen `groups`-Claim mehr („Overage“); dann grei -Wichtig: Jeder Benutzer, der sich per Microsoft anmelden soll, braucht in WordPress **dieselbe E-Mail-Adresse** wie in Microsoft 365. +
+Zusätzlich für den Benutzer-Sync + +| Berechtigung (Anwendung, mit Administratorzustimmung) | Wofür | +| --- | --- | +| `User.Read.All` | Benutzer, Kontostatus, Profilfelder und Profilbilder lesen | +| `GroupMember.Read.All` | Nur nötig, wenn Sync-Gruppen oder Rollen-Zuordnungen verwendet werden | + +Außerdem muss im Tab *Verbindung* die **Tenant-GUID** eingetragen sein (nicht `organizations`/`common`). + +
+ +Wichtig: Jeder Benutzer, der sich per Microsoft anmelden soll, braucht in WordPress **dieselbe E-Mail-Adresse** wie in Microsoft 365 – von Hand angelegt oder vom [Benutzer-Sync](#benutzer-sync) importiert. --- @@ -272,6 +286,65 @@ Das Plugin funktioniert auch, wenn die Anmeldung nicht über `wp-login.php` läu Plugins, die `wp-login.php` umbenennen (z. B. WPS Hide Login), sind kompatibel, weil das Plugin durchgehend `wp_login_url()` verwendet. +### Benutzer-Sync + +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. + +**Welche Benutzer?** Ohne Auswahl alle Mitglieder des Tenants; optional nur Mitglieder bestimmter Gruppen +(verschachtelte Mitgliedschaften zählen). Gäste (B2B) nur auf Wunsch. Die Domain-Allowlist aus dem Tab *Sicherheit* gilt auch hier. + +**Was passiert pro Benutzer?** + +| 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. | +| 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. | +| Nicht mehr in den Sync-Gruppen | Wahlweise nichts tun, deaktivieren oder löschen. | +| Wieder aktiv in Microsoft 365 | Vom Sync deaktivierte Konten werden automatisch reaktiviert (von Hand deaktivierte nicht). | + +**Rollen.** Jeder importierte Benutzer bekommt die *Standardrolle*. Darunter lassen sich Microsoft-365-Gruppen per Suche +auswählen und je einer WordPress-Rolle zuordnen (z. B. „Redaktion“ → Redakteur). Zwei Modi: + +- *Zusätzlich zur Standardrolle* – der Benutzer hat danach mehrere Rollen. +- *Anstelle der Standardrolle* – die erste passende Gruppe der Liste gewinnt (Reihenfolge per ↑). + +Verlässt jemand eine Gruppe, wird die Rolle beim nächsten Lauf entfernt. Die Rollen importierter Konten verwaltet der Sync +vollständig – manuelle Änderungen werden überschrieben. + +**Profilfelder.** Frei wählbar: Anzeigename, Vor- und Nachname, Profilbild, Position, Abteilung, Firma, Büro, Personalnummer, +Telefon (geschäftlich/mobil), Adresse, Sprache. Namen landen in den normalen WordPress-Feldern, alles andere in User-Meta mit +dem Präfix `m365_` (z. B. `m365_department`) und wird auf der Profilseite angezeigt. Das **Profilbild** wird nach +`wp-content/uploads/m365-login-avatars/` geladen (Dateiname mit gesalzenem Hash statt Objekt-ID) und ersetzt überall den +Gravatar; es wird etwa einmal täglich pro Benutzer geprüft. Achtung: Avatare sind öffentlich sichtbar, wo WordPress sie anzeigt. + +**Deaktivierte Konten** können sich überhaupt nicht mehr anmelden – weder per Microsoft noch per Passwort, +Anwendungspasswort oder bestehender Session (alle Sessions werden beendet). In der Benutzerliste zeigt die Spalte +*Microsoft 365* den Status; per Zeilenaktion lassen sich Konten auch von Hand deaktivieren und reaktivieren. + +**Löschen** braucht einen Benutzer, der die Beiträge übernimmt. Ohne Auswahl wird stattdessen deaktiviert – es gehen nie Inhalte verloren. + +**Schutzmechanismen** + +- **Testlauf:** zeigt vollständig, was angelegt, geändert, deaktiviert oder gelöscht würde – ohne etwas zu ändern. +- **Sicherheitsstopp:** Würde ein Lauf mehr als 20 % der verknüpften Konten (mindestens 5) deaktivieren oder löschen, + passiert gar nichts (Filter `m365_login_sync_deprovision_limit`). +- **Fehler = Abbruch:** Schlägt eine Graph-Anfrage fehl, bricht der Lauf ab, bevor irgendein Konto deaktiviert wird. + „Gelöscht“ gilt ein Konto nur, wenn Graph für genau diese Objekt-ID `404` liefert. +- **Geschützte Konten:** Administratoren, die schon vor dem Sync existierten, und das eigene Konto werden nie + deaktiviert, gelöscht oder umgestuft (Filter `m365_login_sync_protect_user`). +- **Sperre gegen Parallelläufe**, Protokoll der letzten Ausführung im Backend. + +**WP-CLI** – empfehlenswert für große Verzeichnisse oder exakte Zeiten per System-Cron: + +```bash +wp m365-login sync --dry-run # Testlauf +wp m365-login sync # echter Lauf +``` + --- ## Sicherheitskonzept @@ -284,7 +357,9 @@ Plugins, die `wp-login.php` umbenennen (z. B. WPS Hide Login), sind kompatibel, | Gefälschte Tokens | Signaturprüfung gegen Microsofts JWKS (`RS256` only; `alg=none`/HMAC werden abgelehnt), Schlüssel-Rollover wird automatisch nachgeladen. `iss`, `aud`, `tid`, `exp`, `nbf`, `iat` werden geprüft. | | Fremde Tenants | Bei konfigurierter Tenant-GUID **Tenant-Pinning**; sonst Issuer-Konsistenz mit `tid`. | | Kontoübernahme per E-Mail-Recycling | **Bindung an die Objekt-ID** (`oid`) beim ersten Login. | -| Unbefugte Konten | Kein Provisioning, optionale Domain-Allowlist, optionale Gruppen-Beschränkung (fail closed). | +| Unbefugte Konten | Der Login legt keine Konten an; optionale Domain-Allowlist, optionale Gruppen-Beschränkung (fail closed). Konten entstehen nur durch den explizit gestarteten bzw. aktivierten Benutzer-Sync. | +| Ausgeschiedene Mitarbeitende | Benutzer-Sync deaktiviert oder löscht Konten, die in Microsoft 365 deaktiviert/gelöscht wurden; deaktivierte Konten verlieren sofort alle Sessions und jeden Anmeldeweg. | +| Massen-Deprovisionierung durch Fehlkonfiguration | Testlauf, Sicherheitsstopp (> 20 % / min. 5), Abbruch bei jedem Graph-Fehler, „gelöscht“ nur bei `404` für die konkrete Objekt-ID, geschützte Administratoren. | | 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`). | @@ -346,6 +421,38 @@ add_filter( 'm365_login_block_password_login', function ( $block, WP_User $user add_filter( 'm365_login_redirect_uri', fn( $uri ) => 'https://www.example.com/m365-login/callback' ); ``` +**Benutzer-Sync** + +```php +// Weiteres Graph-Attribut anbieten (landet in User-Meta "m365_cost_center") +add_filter( 'm365_login_sync_attributes', function ( $attributes ) { + $attributes['costCenter'] = array( 'label' => 'Kostenstelle', 'target' => 'm365_cost_center' ); + return $attributes; +} ); + +// Rollen pro Person anpassen (erste Rolle = Hauptrolle) +add_filter( 'm365_login_sync_roles', function ( array $roles, $oid ) { + return $roles; +}, 10, 2 ); + +// Daten für neu angelegte Konten (Argumente für wp_insert_user) +add_filter( 'm365_login_sync_new_user_data', function ( array $data, array $person ) { + $data['user_login'] = strtolower( $person['userPrincipalName'] ); + return $data; +}, 10, 2 ); + +// Weitere Konten vom Sync ausnehmen +add_filter( 'm365_login_sync_protect_user', function ( $protected, WP_User $user ) { + return $protected || in_array( 'shop_manager', $user->roles, true ); +}, 10, 2 ); + +// Sicherheitsstopp anheben (Standard: 20 % der verknüpften Konten, mindestens 5) +add_filter( 'm365_login_sync_deprovision_limit', fn( $limit, $linked ) => max( 10, $linked * 0.5 ), 10, 2 ); + +// Weitere: m365_login_sync_email, m365_login_sync_photo_limit, m365_login_sync_photo_interval +// Actions: m365_login_sync_user_created, m365_login_sync_finished, m365_login_user_disabled, m365_login_user_enabled +``` + --- ## Fehlerbehebung @@ -361,6 +468,15 @@ add_filter( 'm365_login_redirect_uri', fn( $uri ) => 'https://www.example.com/m3 | *This WordPress account is linked to a different Microsoft account.* | Objekt-ID weicht ab. Wenn gewollt (neues Microsoft-Konto): User-Meta `_m365_login_oid` beim Benutzer löschen. | | *… not a member of a group that is allowed …* | Benutzer ist in keiner der ausgewählten Gruppen. | | *Your group membership could not be verified.* | Graph nicht erreichbar oder Berechtigung fehlt (`User.Read.All`) – oder `groups`-Claim aktivieren. | +| *This account has been deactivated.* | Das Konto wurde vom Benutzer-Sync oder von Hand deaktiviert. *Benutzer → Zeilenaktion „Reaktivieren“* – ist die Person in Microsoft 365 noch deaktiviert, deaktiviert der nächste Sync sie wieder. | + +| Meldung im Sync-Protokoll | Ursache & Lösung | +| --- | --- | +| *Microsoft Graph refused the request …* | Anwendungsberechtigung `User.Read.All` (und bei Gruppen `GroupMember.Read.All`) fehlt oder keine Administratorzustimmung. | +| *The user sync needs a pinned tenant ID …* | Im Tab *Verbindung* die Tenant-GUID statt `organizations`/`common` eintragen. | +| *Safety stop: … accounts would be deactivated or deleted …* | Sync-Gruppen oder Tenant prüfen, Testlauf ansehen; bei gewollter Massenänderung das Limit per Filter anheben. | +| *… is protected … and was not changed.* | Bestehender Administrator oder eigenes Konto – bewusst ausgenommen. | +| Zeitüberschreitung beim Klick auf *Jetzt synchronisieren* | Der Lauf geht serverseitig weiter; Seite später neu laden. Für große Verzeichnisse `wp m365-login sync` verwenden. | Logging: Mit `WP_DEBUG` und `WP_DEBUG_LOG` schreibt das Plugin Fehlerdetails mit Präfix `[M365 Login]` nach `wp-content/debug.log`. Es werden nie Tokens oder Secrets geloggt. @@ -376,7 +492,8 @@ includes/ class-m365-login-crypto.php AES-256-GCM für das Client Secret class-m365-login-jwt.php RS256-Verifikation, JWKS → PEM class-m365-login-auth.php OAuth-Flow, Callback, Benutzerzuordnung, Nur-Button-Modus - class-m365-login-graph.php Client-Credentials-Token, Gruppensuche, checkMemberGroups + class-m365-login-graph.php Client-Credentials-Token, Paging, Benutzer/Gruppen/Fotos, checkMemberGroups + class-m365-login-sync.php Benutzer-Sync, Rollen, Profilfelder/-bilder, Deaktivierung, WP-CLI class-m365-login-button.php Ausgabe auf wp-login.php, Shortcode class-m365-login-admin.php Einstellungsseite, AJAX assets/ CSS/JS für Login-Seite und Backend (unminifiziert) @@ -412,7 +529,7 @@ Das Plugin bringt alles mit, was das Review-Team verlangt: `readme.txt` mit *Ext
Kann ich Benutzer automatisch anlegen lassen? -Nein, bewusst nicht. Der Admin entscheidet, wer ein Konto hat. Wer Auto-Provisioning braucht, kann es über den Hook m365_login_allow_user nicht nachrüsten – das wäre ein anderes Sicherheitsmodell. +Ja, mit dem Benutzer-Sync: Er importiert alle (oder ausgewählte) Microsoft-365-Benutzer vorab als WordPress-Konten. Der Login selbst legt weiterhin nie Konten an – wer nicht importiert oder von Hand angelegt wurde, kommt nicht rein.
@@ -427,7 +544,12 @@ Ja. Einstellungen gelten pro Site; der Benutzer muss Mitglied der Site (oder Sup
Was passiert beim Deinstallieren? -Einstellungen, Caches (Transients) und die pro Benutzer gespeicherte Objekt-ID werden entfernt – auch in Multisite. +Einstellungen, Caches (Transients), Sync-Protokoll, Cron-Termin, gespeicherte Profilbilder und die pro Benutzer gespeicherten Plugin-Daten (Objekt-ID, Deaktivierungs-Status) werden entfernt – auch in Multisite. Importierte Konten und übernommene Profilfelder (m365_*) bleiben erhalten. Deaktivierte Konten sind danach wieder aktiv; wer sie sperren will, sollte sie vorher löschen. +
+ +
+Benutzer-Sync und Multisite? +Der Sync arbeitet pro Site: Neue Konten werden zur aktuellen Site hinzugefügt, „Löschen“ entfernt das Konto nur aus dieser Site. Die Deaktivierung gilt netzwerkweit, weil sie am Benutzer hängt.
diff --git a/assets/css/admin.css b/assets/css/admin.css index c627454..d38524d 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -945,3 +945,177 @@ color: #8a2424; border: 1px solid #f0b8bd; } + +/* User sync */ +.m365-card__subtitle { + font-size: 13px; + font-weight: 600; + margin: 24px 0 4px; +} + +.m365-picker { + margin-top: 12px; +} + +.m365-group-list--roles { + flex-direction: column; + align-items: stretch; +} + +.m365-group-chip--role { + border-radius: 8px; + padding: 6px 8px; +} + +.m365-group-chip--role .m365-group-chip__name { + flex: 0 1 auto; +} + +.m365-group-chip--role .m365-group-chip__id { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.m365-field .m365-group-chip select { + width: auto; + min-height: 28px; + flex: 0 0 auto; +} + +.m365-group-chip__arrow { + color: var(--m365-muted); +} + +.m365-group-chip__up { + appearance: none; + border: 0; + background: #fff; + color: var(--m365-accent); + width: 22px; + height: 22px; + border-radius: 50%; + line-height: 1; + cursor: pointer; +} + +.m365-group-chip--role:first-child .m365-group-chip__up { + visibility: hidden; +} + +.m365-attributes { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 20px; + margin-bottom: 12px; +} + +.m365-attributes code { + font-size: 11px; + color: var(--m365-muted); + background: transparent; + padding: 0; +} + +@media (max-width: 782px) { + .m365-attributes { + grid-template-columns: 1fr; + } +} + +.m365-sync-report-wrap { + margin-top: 16px; +} + +.m365-sync-report__head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin: 0 0 10px; +} + +.m365-sync-report__status { + padding: 2px 10px; + border-radius: 999px; + font-weight: 600; + font-size: 12px; + background: #e6f4ea; + color: #1e6b31; +} + +.m365-sync-report__status.is-bad { + background: #fcf0f1; + color: #8a1f22; +} + +.m365-sync-report__dry { + padding: 2px 10px; + border-radius: 999px; + font-size: 12px; + background: #f0f6fc; + color: #0a4b78; +} + +.m365-sync-report__meta { + color: var(--m365-muted); + font-size: 12px; +} + +.m365-sync-report__counts { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 0 0 10px; + padding: 0; + list-style: none; +} + +.m365-sync-report__count { + margin: 0; + padding: 4px 10px; + border: 1px solid var(--m365-border); + border-radius: 6px; + background: #fff; + font-size: 12px; +} + +.m365-sync-report__count--disabled strong, +.m365-sync-report__count--deleted strong, +.m365-sync-report__count--errors strong { + color: #b32d2e; +} + +.m365-sync-report__details summary { + cursor: pointer; + font-weight: 600; +} + +.m365-sync-log { + margin: 8px 0 0; + padding: 8px 12px; + max-height: 360px; + overflow-y: auto; + background: #fff; + border: 1px solid var(--m365-border); + border-radius: 6px; + font-size: 12px; + list-style: none; +} + +.m365-sync-log__item { + margin: 0; + padding: 3px 0 3px 10px; + border-left: 3px solid #c5d9ed; + margin-bottom: 2px; +} + +.m365-sync-log__item.is-warning { + border-left-color: #dba617; +} + +.m365-sync-log__item.is-error { + border-left-color: #b32d2e; + color: #8a1f22; +} diff --git a/assets/js/admin.js b/assets/js/admin.js index a8cbb45..322fd04 100644 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -221,101 +221,165 @@ } } ); - /* ---------------- Entra group picker ---------------- */ - var $groupList = $( '#m365-group-list' ); - var $groupResults = $( '#m365-group-results' ); + /* ---------------- Entra group pickers ---------------- */ var optionName = ( $( '#m365-tenant' ).attr( 'name' ) || '' ).replace( /\[tenant_id\]$/, '' ); + var guidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; function escapeHtml( str ) { return $( '
' ).text( str || '' ).html(); } - function refreshGroupList() { - $groupList.toggleClass( 'is-empty', 0 === $groupList.children( 'li' ).length ); - } + $( '.m365-picker' ).each( function () { + var $picker = $( this ); + var field = $picker.data( 'field' ); + var mode = $picker.data( 'mode' ); + var $list = $picker.find( '.m365-picker__list' ); + var $results = $picker.find( '.m365-picker__results' ); + var $query = $picker.find( '.m365-picker__query' ); + var base = optionName + '[' + field + ']'; - function addGroup( id, name ) { - id = ( id || '' ).toLowerCase(); - if ( ! /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test( id ) ) { - return; + function refresh() { + $list.toggleClass( 'is-empty', 0 === $list.children( 'li' ).length ); } - if ( $groupList.find( 'li[data-id="' + id + '"]' ).length ) { - return; + + function has( id ) { + return $list.find( 'li[data-id="' + id + '"]' ).length > 0; } - var $li = $( '
  • ' ).attr( 'data-id', id ); - $li.append( $( '' ).text( name || id ) ); - $li.append( $( '' ).text( id ) ); - $li.append( $( '' ).attr( 'name', optionName + '[allowed_groups][' + id + ']' ).val( name || id ) ); - $li.append( $( '' ) ); - $groupList.append( $li ); - refreshGroupList(); - } - $groupList.on( 'click', '.m365-group-chip__remove', function () { - $( this ).closest( 'li' ).remove(); - refreshGroupList(); - } ); - refreshGroupList(); - - function searchGroups() { - var query = $.trim( $( '#m365-group-search' ).val() ); - $groupResults.prop( 'hidden', false ).removeClass( 'is-error' ).html( '

    ' + escapeHtml( i18n.searching ) + '

    ' ); - - $.post( cfg.ajaxUrl, { - action: cfg.groupAction, - nonce: cfg.nonce, - query: query - } ).done( function ( res ) { - if ( ! res || ! res.success ) { - var msg = ( res && res.data && res.data.message ) || i18n.testFailed; - $groupResults.addClass( 'is-error' ).html( '

    ' + escapeHtml( msg ) + '

    ' ); - // Allow adding a pasted GUID even when Graph is unavailable. - if ( /^[0-9a-f-]{36}$/i.test( query ) ) { - $groupResults.append( buildResult( { id: query, name: query, type: '', description: '' } ) ); - } + function add( id, name ) { + id = ( id || '' ).toLowerCase(); + if ( ! guidPattern.test( id ) || has( id ) ) { return; } - var groups = res.data.groups || []; - if ( ! groups.length ) { - $groupResults.html( '

    ' + escapeHtml( i18n.noGroups ) + '

    ' ); - return; + var $li = $( '
  • ' ).attr( 'data-id', id ); + if ( 'roles' === mode ) { + $li.addClass( 'm365-group-chip--role' ); + $li.append( $( '' ).attr( 'aria-label', i18n.moveUp || '' ) ); } - $groupResults.empty(); - $.each( groups, function ( i, g ) { - $groupResults.append( buildResult( g ) ); + $li.append( $( '' ).text( name || id ) ); + $li.append( $( '' ).text( id ) ); + if ( 'roles' === mode ) { + $li.append( '' ); + var $select = $picker.find( '.m365-picker__role-template' ).clone(); + $select.removeClass( 'm365-picker__role-template' ).prop( 'hidden', false ).prop( 'disabled', false ).attr( 'name', base + '[' + id + '][role]' ); + $li.append( $select ); + $li.append( $( '' ).attr( 'name', base + '[' + id + '][name]' ).val( name || id ) ); + } else { + $li.append( $( '' ).attr( 'name', base + '[' + id + ']' ).val( name || id ) ); + } + $li.append( $( '' ).attr( 'aria-label', i18n.remove || '' ) ); + $list.append( $li ); + refresh(); + markDirty(); + } + + $list.on( 'click', '.m365-group-chip__remove', function () { + $( this ).closest( 'li' ).remove(); + refresh(); + markDirty(); + } ); + $list.on( 'click', '.m365-group-chip__up', function () { + var $li = $( this ).closest( 'li' ); + $li.prev().before( $li ); + markDirty(); + } ); + refresh(); + + function buildResult( g ) { + var $row = $( '
    ' ); + var $meta = $( '
    ' ); + $meta.append( $( '' ).text( g.name ) ); + if ( g.type ) { + $meta.append( $( '' ).text( g.type ) ); + } + $meta.append( $( '' ).text( g.id ) ); + if ( g.description ) { + $meta.append( $( '' ).text( g.description ) ); + } + var $btn = $( ' +
    + +

    + +

    + + +
    + +
    + +
      + $value ) : ?> + +
    • + + + + + + + + + + + + + +
    • + +
    + + + +
    +
  • + settings->is_configured(); + $next = wp_next_scheduled( M365_Login_Sync::CRON_HOOK ); + $actions = array( + 'none' => __( 'Do nothing', 'm365-login' ), + 'disable' => __( 'Deactivate the WordPress account', 'm365-login' ), + 'delete' => __( 'Delete the WordPress account', 'm365-login' ), + ); + $selects = array( + 'sync_disabled_action' => __( 'Account disabled in Microsoft 365 (sign-in blocked)', 'm365-login' ), + 'sync_deleted_action' => __( 'Account deleted in Microsoft 365', 'm365-login' ), + 'sync_scope_action' => __( 'No longer a member of the sync groups', 'm365-login' ), + ); + ?> +
    +
    +

    +

    + + + +
    +
    + + + + +

    + +
    +
    + + + +

    +

    + group_picker( 'sync_scope_groups', $this->settings->sync_scope_groups(), 'chips', __( 'No groups selected – all users of the tenant are imported.', 'm365-login' ) ); ?> +
    + +
    +

    + +
    + + +

    +
    + +

    +

    + group_picker( 'sync_role_map', $this->settings->sync_role_map(), 'roles', __( 'No group mapping – everybody gets the default role.', 'm365-login' ) ); ?> + +
    + + + +
    + + +
    + +
    +

    +

    +
    + $attribute ) : ?> + + +
    +

    +
    + +
    +

    +

    + + $label ) : ?> +
    + + + +

    + +
    + + +
    + + M365_LOGIN_OPTION . '[sync_reassign]', + 'id' => 'm365-sync-reassign', + 'selected' => (int) $s['sync_reassign'], + 'show_option_none' => __( '— Select a user —', 'm365-login' ), + 'option_none_value' => 0, + 'capability' => array( 'edit_posts' ), + 'echo' => false, + ) + ); + ?> +

    +
    + +

    +
    + +
    +

    +

    +
    + + +
    +

    +
    + report_markup( M365_Login_Sync::last_report() ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in report_markup(). ?> +
    +
    +
    +

    -

    +

    @@ -807,11 +1126,13 @@ class M365_Login_Admin {
  • -
  • +
  • + render_sync_panel( $s ); ?> +
    @@ -846,7 +1167,7 @@ class M365_Login_Admin {
  • -

    +

    diff --git a/includes/class-m365-login-auth.php b/includes/class-m365-login-auth.php index 757d9b3..810e99a 100644 --- a/includes/class-m365-login-auth.php +++ b/includes/class-m365-login-auth.php @@ -441,6 +441,10 @@ class M365_Login_Auth { $this->fail( 'no_user' ); } + if ( M365_Login_Sync::disabled_info( $user->ID ) ) { + $this->fail( 'account_disabled' ); + } + $oid = isset( $claims['oid'] ) && is_string( $claims['oid'] ) ? strtolower( $claims['oid'] ) : ''; // Entra group restriction. @@ -857,6 +861,7 @@ class M365_Login_Auth { 'fallback_invalid' => __( 'The fallback key is not valid.', 'm365-login' ), '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' ), ); } diff --git a/includes/class-m365-login-graph.php b/includes/class-m365-login-graph.php index ba1f6e5..027c485 100644 --- a/includes/class-m365-login-graph.php +++ b/includes/class-m365-login-graph.php @@ -8,7 +8,7 @@ defined( 'ABSPATH' ) || exit; /** - * Obtains app-only tokens via client credentials and queries groups. + * Obtains app-only tokens via client credentials and queries users and groups. */ class M365_Login_Graph { @@ -98,6 +98,63 @@ class M365_Login_Graph { return (string) $body['access_token']; } + /** + * Performs an authenticated Graph request and returns the raw HTTP response. + * + * Retries a few times when Microsoft throttles (HTTP 429) or is briefly unavailable (503/504). + * + * @param string $method HTTP method. + * @param string $path Path relative to the v1.0 base (with query string) or an absolute Graph URL (paging links). + * @param array|null $json JSON body for POST requests. + * @param array $headers Extra headers. + * @return array|WP_Error Response array from wp_remote_request(). + */ + private function raw_request( $method, $path, $json = null, $headers = array() ) { + $url = 0 === strpos( $path, self::GRAPH_BASE . '/' ) ? $path : self::GRAPH_BASE . $path; + if ( 0 !== strpos( $url, self::GRAPH_BASE . '/' ) ) { + return new WP_Error( 'graph_bad_url', 'Refusing to call a non-Graph URL.' ); + } + + for ( $attempt = 1; ; $attempt++ ) { + $token = $this->app_token(); + if ( is_wp_error( $token ) ) { + return $token; + } + + $args = array( + 'method' => $method, + 'timeout' => self::HTTP_TIMEOUT, + 'headers' => array_merge( + array( + 'Authorization' => 'Bearer ' . $token, + 'Accept' => 'application/json', + ), + $headers + ), + ); + if ( null !== $json ) { + $args['headers']['Content-Type'] = 'application/json'; + $args['body'] = wp_json_encode( $json ); + } + + $response = wp_remote_request( $url, $args ); + if ( is_wp_error( $response ) ) { + return $response; + } + + $code = (int) wp_remote_retrieve_response_code( $response ); + if ( 401 === $code ) { + $this->flush_token(); + } + if ( $attempt < 4 && in_array( $code, array( 429, 503, 504 ), true ) ) { + $wait = (int) wp_remote_retrieve_header( $response, 'retry-after' ); + sleep( max( 1, min( 10, $wait > 0 ? $wait : $attempt * 2 ) ) ); + continue; + } + return $response; + } + } + /** * Performs an authenticated Graph request. * @@ -105,31 +162,10 @@ class M365_Login_Graph { * @param string $path Path relative to the v1.0 base (with query string). * @param array|null $json JSON body for POST requests. * @param array $headers Extra headers. - * @return array|WP_Error Decoded JSON. + * @return array|WP_Error Decoded JSON. Errors carry array( 'status' => HTTP code ) as data. */ private function request( $method, $path, $json = null, $headers = array() ) { - $token = $this->app_token(); - if ( is_wp_error( $token ) ) { - return $token; - } - - $args = array( - 'method' => $method, - 'timeout' => self::HTTP_TIMEOUT, - 'headers' => array_merge( - array( - 'Authorization' => 'Bearer ' . $token, - 'Accept' => 'application/json', - ), - $headers - ), - ); - if ( null !== $json ) { - $args['headers']['Content-Type'] = 'application/json'; - $args['body'] = wp_json_encode( $json ); - } - - $response = wp_remote_request( self::GRAPH_BASE . $path, $args ); + $response = $this->raw_request( $method, $path, $json, $headers ); if ( is_wp_error( $response ) ) { return $response; } @@ -137,18 +173,156 @@ class M365_Login_Graph { $code = (int) wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); - if ( 401 === $code ) { - $this->flush_token(); - } if ( $code < 200 || $code >= 300 || ! is_array( $body ) ) { - $graph_code = isset( $body['error']['code'] ) ? (string) $body['error']['code'] : 'HTTP ' . $code; - $message = isset( $body['error']['message'] ) ? (string) $body['error']['message'] : ''; - return new WP_Error( 'graph_' . sanitize_key( $graph_code ), $graph_code . ( $message ? ': ' . $message : '' ) ); + return $this->error_from( $code, $body ); } return $body; } + /** + * Builds a WP_Error from a failed Graph response. + * + * @param int $code HTTP status. + * @param array|null $body Decoded body. + * @return WP_Error + */ + private function error_from( $code, $body ) { + $graph_code = isset( $body['error']['code'] ) ? (string) $body['error']['code'] : 'HTTP ' . $code; + $message = isset( $body['error']['message'] ) ? (string) $body['error']['message'] : ''; + return new WP_Error( 'graph_' . sanitize_key( $graph_code ), $graph_code . ( $message ? ': ' . $message : '' ), array( 'status' => (int) $code ) ); + } + + /** + * Whether a Graph error means "object does not exist". + * + * @param WP_Error $error Error. + * @return bool + */ + public static function is_not_found( $error ) { + $data = $error->get_error_data(); + return is_array( $data ) && isset( $data['status'] ) && 404 === (int) $data['status']; + } + + /** + * Follows @odata.nextLink until every page of a collection is read. + * + * @param string $path First page (relative path). + * @param array $headers Extra headers. + * @return array[]|WP_Error All items. + */ + private function collect( $path, $headers = array() ) { + $items = array(); + $next = $path; + $pages = 0; + while ( '' !== $next ) { + if ( ++$pages > 1000 ) { + return new WP_Error( 'graph_paging', 'Too many result pages.' ); + } + $result = $this->request( 'GET', $next, null, $headers ); + if ( is_wp_error( $result ) ) { + return $result; + } + if ( isset( $result['value'] ) && is_array( $result['value'] ) ) { + foreach ( $result['value'] as $item ) { + if ( is_array( $item ) && ! empty( $item['id'] ) ) { + $items[] = $item; + } + } + } + $next = isset( $result['@odata.nextLink'] ) && is_string( $result['@odata.nextLink'] ) ? $result['@odata.nextLink'] : ''; + } + return $items; + } + + /** + * Lists every user of the tenant. + * + * @param string[] $select Properties to read. + * @return array[]|WP_Error + */ + public function list_users( $select ) { + return $this->collect( '/users?$select=' . rawurlencode( implode( ',', $select ) ) . '&$top=999' ); + } + + /** + * Lists the users that are (directly or through nested groups) members of a group. + * + * @param string $group_id Group object ID. + * @param string[] $select Properties to read. + * @return array[]|WP_Error + */ + public function list_group_users( $group_id, $select ) { + if ( ! M365_Login_Settings::is_guid( $group_id ) ) { + return new WP_Error( 'graph_bad_group', 'Invalid group object ID.' ); + } + return $this->collect( + '/groups/' . rawurlencode( strtolower( $group_id ) ) . '/transitiveMembers/microsoft.graph.user?$select=' . rawurlencode( implode( ',', $select ) ) . '&$top=999&$count=true', + array( 'ConsistencyLevel' => 'eventual' ) + ); + } + + /** + * Reads a single user. + * + * @param string $oid User object ID. + * @param string[] $select Properties to read. + * @return array|WP_Error WP_Error with status 404 when the user does not exist (anymore). + */ + public function get_user( $oid, $select ) { + if ( ! M365_Login_Settings::is_guid( $oid ) ) { + return new WP_Error( 'graph_bad_oid', 'Invalid user object ID.' ); + } + return $this->request( 'GET', '/users/' . rawurlencode( strtolower( $oid ) ) . '?$select=' . rawurlencode( implode( ',', $select ) ) ); + } + + /** + * Metadata of a user's profile photo (prefers the 240×240 rendition). + * + * @param string $oid User object ID. + * @return array|null|WP_Error array( 'path' => photo path, 'etag' => string ), null when the user has no photo. + */ + public function photo_info( $oid ) { + if ( ! M365_Login_Settings::is_guid( $oid ) ) { + return new WP_Error( 'graph_bad_oid', 'Invalid user object ID.' ); + } + $base = '/users/' . rawurlencode( strtolower( $oid ) ); + foreach ( array( $base . '/photos/240x240', $base . '/photo' ) as $path ) { + $meta = $this->request( 'GET', $path ); + if ( is_wp_error( $meta ) ) { + if ( self::is_not_found( $meta ) ) { + continue; + } + return $meta; + } + $etag = isset( $meta['@odata.mediaEtag'] ) ? (string) $meta['@odata.mediaEtag'] : ''; + return array( + 'path' => $path, + 'etag' => '' !== $etag ? $etag : md5( (string) wp_json_encode( $meta ) ), + ); + } + return null; + } + + /** + * Downloads photo bytes. + * + * @param string $path Photo path returned by photo_info(). + * @return string|WP_Error Binary image data. + */ + public function photo_bytes( $path ) { + $response = $this->raw_request( 'GET', $path . '/$value', null, array( 'Accept' => 'image/*' ) ); + if ( is_wp_error( $response ) ) { + return $response; + } + $code = (int) wp_remote_retrieve_response_code( $response ); + $body = wp_remote_retrieve_body( $response ); + if ( 200 !== $code ) { + return $this->error_from( $code, json_decode( $body, true ) ); + } + return $body; + } + /** * Searches groups by display name. * diff --git a/includes/class-m365-login-settings.php b/includes/class-m365-login-settings.php index a4b8bd6..5852f00 100644 --- a/includes/class-m365-login-settings.php +++ b/includes/class-m365-login-settings.php @@ -34,36 +34,50 @@ class M365_Login_Settings { public function defaults() { return array( // Connection. - 'tenant_id' => '', - 'client_id' => '', - 'client_secret' => '', // Stored encrypted. - 'auth_method' => 'secret', // 'secret' or 'certificate'. - 'cert_private_key' => '', // PEM, stored encrypted. - 'cert_certificate' => '', // PEM (public). - 'prompt' => 'select_account', + 'tenant_id' => '', + 'client_id' => '', + 'client_secret' => '', // Stored encrypted. + 'auth_method' => 'secret', // 'secret' or 'certificate'. + 'cert_private_key' => '', // PEM, stored encrypted. + 'cert_certificate' => '', // PEM (public). + 'prompt' => 'select_account', // Security / matching. - 'upn_fallback' => 1, - 'bind_oid' => 1, - 'allowed_domains' => '', - 'allowed_groups' => array(), // id => display name. - 'remember_me' => 0, + 'upn_fallback' => 1, + 'bind_oid' => 1, + 'allowed_domains' => '', + 'allowed_groups' => array(), // id => display name. + 'remember_me' => 0, // Button-only mode. - 'button_only' => 0, - 'fallback_key' => '', + 'button_only' => 0, + 'fallback_key' => '', // Button appearance. - 'button_text' => __( 'Sign in with Microsoft', 'm365-login' ), - 'button_icon' => '', // Empty = bundled Microsoft logo. - 'button_show_icon' => 1, - 'button_bg' => '#2f2f2f', - 'button_bg_hover' => '#1a1a1a', - 'button_color' => '#ffffff', - 'button_border' => '#2f2f2f', - 'button_radius' => 4, - 'button_position' => 'below', - 'divider_text' => __( 'or', 'm365-login' ), + 'button_text' => __( 'Sign in with Microsoft', 'm365-login' ), + 'button_icon' => '', // Empty = bundled Microsoft logo. + 'button_show_icon' => 1, + 'button_bg' => '#2f2f2f', + 'button_bg_hover' => '#1a1a1a', + 'button_color' => '#ffffff', + 'button_border' => '#2f2f2f', + 'button_radius' => 4, + 'button_position' => 'below', + 'divider_text' => __( 'or', 'm365-login' ), // Custom login pages. - 'custom_login_url' => '', - 'inject_form' => 1, // Add the button to wp_login_form() output. + 'custom_login_url' => '', + 'inject_form' => 1, // Add the button to wp_login_form() output. + // User sync. + 'sync_enabled' => 0, // Scheduled sync via WP-Cron. + 'sync_interval' => 'daily', + 'sync_guests' => 0, + 'sync_scope_groups' => array(), // id => display name; empty = whole tenant. + 'sync_default_role' => 'subscriber', + 'sync_role_map' => array(), // id => array( 'name' => .., 'role' => .. ), in priority order. + 'sync_role_mode' => 'add', // 'add' (extra roles) or 'replace' (first match replaces the default role). + 'sync_manage_existing' => 0, // Also manage roles of accounts that existed before the sync. + 'sync_attributes' => array( 'displayName', 'givenName', 'surname' ), + 'sync_disabled_action' => 'disable', // Account disabled in Microsoft 365: none|disable|delete. + 'sync_deleted_action' => 'disable', // Account deleted in Microsoft 365: none|disable|delete. + 'sync_scope_action' => 'none', // Removed from the sync groups: none|disable|delete. + 'sync_reassign' => 0, // User ID that receives content of deleted users. ); } @@ -80,6 +94,13 @@ class M365_Login_Settings { return $this->cache; } + /** + * Drops the cached settings (after the option was written). + */ + public function flush() { + $this->cache = null; + } + /** * Returns a single setting. * @@ -309,7 +330,47 @@ class M365_Login_Settings { * @return array */ public function allowed_groups() { - $raw = $this->get( 'allowed_groups', array() ); + return self::guid_map( $this->get( 'allowed_groups', array() ) ); + } + + /** + * Groups that limit the user sync (lowercase GUID => name); empty = whole tenant. + * + * @return array + */ + public function sync_scope_groups() { + return self::guid_map( $this->get( 'sync_scope_groups', array() ) ); + } + + /** + * Group → role mapping in priority order. + * + * @return array lowercase GUID => array( 'name' => string, 'role' => string ). + */ + public function sync_role_map() { + $raw = $this->get( 'sync_role_map', array() ); + $out = array(); + if ( is_array( $raw ) ) { + foreach ( $raw as $id => $row ) { + $id = strtolower( (string) $id ); + if ( self::is_guid( $id ) && is_array( $row ) && ! empty( $row['role'] ) ) { + $out[ $id ] = array( + 'name' => isset( $row['name'] ) ? (string) $row['name'] : $id, + 'role' => (string) $row['role'], + ); + } + } + } + return $out; + } + + /** + * Keeps GUID keys (lowercased) of an id => name array. + * + * @param mixed $raw Stored value. + * @return array + */ + private static function guid_map( $raw ) { $out = array(); if ( is_array( $raw ) ) { foreach ( $raw as $id => $name ) { @@ -449,8 +510,8 @@ class M365_Login_Settings { // Certificate: keep the stored pair unless a new one is pasted or removal is requested. $out['cert_private_key'] = $current['cert_private_key']; $out['cert_certificate'] = $current['cert_certificate']; - $pasted_key = isset( $input['cert_key_pem'] ) ? trim( (string) wp_unslash( $input['cert_key_pem'] ) ) : ''; - $pasted_cert = isset( $input['cert_cert_pem'] ) ? trim( (string) wp_unslash( $input['cert_cert_pem'] ) ) : ''; + $pasted_key = isset( $input['cert_key_pem'] ) ? trim( (string) wp_unslash( $input['cert_key_pem'] ) ) : ''; + $pasted_cert = isset( $input['cert_cert_pem'] ) ? trim( (string) wp_unslash( $input['cert_cert_pem'] ) ) : ''; if ( ! empty( $input['cert_remove'] ) ) { $out['cert_private_key'] = ''; $out['cert_certificate'] = ''; @@ -485,26 +546,12 @@ class M365_Login_Settings { $out['bind_oid'] = empty( $input['bind_oid'] ) ? 0 : 1; $out['remember_me'] = empty( $input['remember_me'] ) ? 0 : 1; - $domains = isset( $input['allowed_domains'] ) ? sanitize_textarea_field( wp_unslash( $input['allowed_domains'] ) ) : ''; - $domains = preg_replace( '/[^a-z0-9.\-@,;\s]/i', '', $domains ); + $domains = isset( $input['allowed_domains'] ) ? sanitize_textarea_field( wp_unslash( $input['allowed_domains'] ) ) : ''; + $domains = preg_replace( '/[^a-z0-9.\-@,;\s]/i', '', $domains ); $out['allowed_domains'] = trim( (string) $domains ); // Allowed groups: GUID => name. - $groups = array(); - if ( ! empty( $input['allowed_groups'] ) && is_array( $input['allowed_groups'] ) ) { - foreach ( $input['allowed_groups'] as $id => $name ) { - $id = strtolower( trim( sanitize_text_field( wp_unslash( (string) $id ) ) ) ); - if ( ! self::is_guid( $id ) ) { - continue; - } - $name = sanitize_text_field( wp_unslash( (string) $name ) ); - $groups[ $id ] = '' === $name ? $id : mb_substr( $name, 0, 120 ); - if ( count( $groups ) >= 100 ) { - break; - } - } - } - $out['allowed_groups'] = $groups; + $out['allowed_groups'] = self::sanitize_group_list( isset( $input['allowed_groups'] ) ? $input['allowed_groups'] : array() ); // Button-only mode + fallback key. $out['button_only'] = empty( $input['button_only'] ) ? 0 : 1; @@ -551,11 +598,114 @@ class M365_Login_Settings { $divider = isset( $input['divider_text'] ) ? sanitize_text_field( wp_unslash( $input['divider_text'] ) ) : ''; $out['divider_text'] = mb_substr( $divider, 0, 40 ); + $out = $this->sanitize_sync( $input, $out ); + $this->cache = null; return $out; } + /** + * Sanitises the user sync settings. + * + * @param array $input Raw input. + * @param array $out Settings sanitised so far. + * @return array + */ + private function sanitize_sync( $input, $out ) { + $defaults = $this->defaults(); + + $out['sync_enabled'] = empty( $input['sync_enabled'] ) ? 0 : 1; + $out['sync_guests'] = empty( $input['sync_guests'] ) ? 0 : 1; + $out['sync_manage_existing'] = empty( $input['sync_manage_existing'] ) ? 0 : 1; + + $interval = isset( $input['sync_interval'] ) ? sanitize_key( $input['sync_interval'] ) : ''; + $out['sync_interval'] = in_array( $interval, array( 'hourly', 'twicedaily', 'daily' ), true ) ? $interval : $defaults['sync_interval']; + + $mode = isset( $input['sync_role_mode'] ) ? sanitize_key( $input['sync_role_mode'] ) : ''; + $out['sync_role_mode'] = in_array( $mode, array( 'add', 'replace' ), true ) ? $mode : $defaults['sync_role_mode']; + + $role = isset( $input['sync_default_role'] ) ? sanitize_key( $input['sync_default_role'] ) : ''; + $out['sync_default_role'] = '' !== $role && get_role( $role ) ? $role : $defaults['sync_default_role']; + + $out['sync_scope_groups'] = self::sanitize_group_list( isset( $input['sync_scope_groups'] ) ? $input['sync_scope_groups'] : array() ); + + $map = array(); + if ( ! empty( $input['sync_role_map'] ) && is_array( $input['sync_role_map'] ) ) { + foreach ( $input['sync_role_map'] as $id => $row ) { + $id = strtolower( trim( sanitize_text_field( wp_unslash( (string) $id ) ) ) ); + if ( ! self::is_guid( $id ) || ! is_array( $row ) ) { + continue; + } + $map_role = isset( $row['role'] ) ? sanitize_key( $row['role'] ) : ''; + if ( '' === $map_role || ! get_role( $map_role ) ) { + continue; + } + $name = isset( $row['name'] ) ? sanitize_text_field( wp_unslash( (string) $row['name'] ) ) : ''; + $map[ $id ] = array( + 'name' => '' === $name ? $id : mb_substr( $name, 0, 120 ), + 'role' => $map_role, + ); + if ( count( $map ) >= 100 ) { + break; + } + } + } + $out['sync_role_map'] = $map; + + $attributes = array(); + if ( ! empty( $input['sync_attributes'] ) && is_array( $input['sync_attributes'] ) ) { + $known = array_keys( M365_Login_Sync::attributes() ); + foreach ( $input['sync_attributes'] as $attribute ) { + $attribute = sanitize_text_field( wp_unslash( (string) $attribute ) ); + if ( in_array( $attribute, $known, true ) ) { + $attributes[] = $attribute; + } + } + } + $out['sync_attributes'] = array_values( array_unique( $attributes ) ); + + foreach ( array( 'sync_disabled_action', 'sync_deleted_action', 'sync_scope_action' ) as $key ) { + $action = isset( $input[ $key ] ) ? sanitize_key( $input[ $key ] ) : ''; + $out[ $key ] = in_array( $action, array( 'none', 'disable', 'delete' ), true ) ? $action : $defaults[ $key ]; + } + + $reassign = isset( $input['sync_reassign'] ) ? absint( $input['sync_reassign'] ) : 0; + $out['sync_reassign'] = $reassign && get_userdata( $reassign ) ? $reassign : 0; + + $deletes = in_array( 'delete', array( $out['sync_disabled_action'], $out['sync_deleted_action'], $out['sync_scope_action'] ), true ); + if ( $deletes && ! $out['sync_reassign'] ) { + add_settings_error( M365_LOGIN_OPTION, 'sync_reassign', __( 'User sync: "Delete" needs a user who receives the posts of deleted accounts. Until one is selected, accounts are deactivated instead.', 'm365-login' ), 'warning' ); + } + + return $out; + } + + /** + * Sanitises a GUID => name list posted by a group picker. + * + * @param mixed $raw Raw input. + * @return array + */ + private static function sanitize_group_list( $raw ) { + $groups = array(); + if ( empty( $raw ) || ! is_array( $raw ) ) { + return $groups; + } + foreach ( $raw as $id => $name ) { + $id = strtolower( trim( sanitize_text_field( wp_unslash( (string) $id ) ) ) ); + if ( ! self::is_guid( $id ) ) { + continue; + } + $name = sanitize_text_field( wp_unslash( (string) $name ) ); + $groups[ $id ] = '' === $name ? $id : mb_substr( $name, 0, 120 ); + if ( count( $groups ) >= 100 ) { + break; + } + } + return $groups; + } + /** * Checks a GUID. * diff --git a/includes/class-m365-login-sync.php b/includes/class-m365-login-sync.php new file mode 100644 index 0000000..5eb1022 --- /dev/null +++ b/includes/class-m365-login-sync.php @@ -0,0 +1,1578 @@ +settings = $settings; + $this->graph = $graph; + + add_action( self::CRON_HOOK, array( $this, 'run_scheduled' ) ); + add_action( 'init', array( $this, 'ensure_schedule' ) ); + add_action( 'update_option_' . M365_LOGIN_OPTION, array( $this, 'reschedule' ) ); + add_action( 'add_option_' . M365_LOGIN_OPTION, array( $this, 'reschedule' ) ); + + // Deactivated accounts: no password, application password, cookie or Microsoft sign-in. + add_filter( 'authenticate', array( $this, 'block_disabled_login' ), 100, 1 ); + add_filter( 'determine_current_user', array( $this, 'drop_disabled_session' ), 100 ); + + add_filter( 'pre_get_avatar_data', array( $this, 'avatar_data' ), 10, 2 ); + + if ( is_admin() ) { + add_filter( 'manage_users_columns', array( $this, 'users_column' ) ); + add_filter( 'manage_users_custom_column', array( $this, 'users_column_value' ), 10, 3 ); + add_filter( 'user_row_actions', array( $this, 'user_row_actions' ), 10, 2 ); + 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( 'admin_notices', array( $this, 'user_state_notice' ) ); + } + + if ( defined( 'WP_CLI' ) && WP_CLI ) { + WP_CLI::add_command( 'm365-login sync', array( $this, 'cli' ) ); + } + } + + /* ------------------------------------------------------------------ */ + /* Attributes */ + /* ------------------------------------------------------------------ */ + + /** + * Microsoft Graph user properties that can be copied into WordPress profiles. + * + * The target is either a WordPress user field (display_name, first_name, last_name, + * locale), a user meta key or "avatar" for the profile photo. + * + * @return array Graph property => array( 'label' => string, 'target' => string ). + */ + public static function attributes() { + $attributes = array( + 'displayName' => array( + 'label' => __( 'Display name', 'm365-login' ), + 'target' => 'display_name', + ), + 'givenName' => array( + 'label' => __( 'First name', 'm365-login' ), + 'target' => 'first_name', + ), + 'surname' => array( + 'label' => __( 'Last name', 'm365-login' ), + 'target' => 'last_name', + ), + 'photo' => array( + 'label' => __( 'Profile photo (used as avatar)', 'm365-login' ), + 'target' => 'avatar', + ), + 'jobTitle' => array( + 'label' => __( 'Job title', 'm365-login' ), + 'target' => 'm365_job_title', + ), + 'department' => array( + 'label' => __( 'Department', 'm365-login' ), + 'target' => 'm365_department', + ), + 'companyName' => array( + 'label' => __( 'Company', 'm365-login' ), + 'target' => 'm365_company_name', + ), + 'officeLocation' => array( + 'label' => __( 'Office', 'm365-login' ), + 'target' => 'm365_office_location', + ), + 'employeeId' => array( + 'label' => __( 'Employee ID', 'm365-login' ), + 'target' => 'm365_employee_id', + ), + 'businessPhones' => array( + 'label' => __( 'Business phone', 'm365-login' ), + 'target' => 'm365_business_phone', + ), + 'mobilePhone' => array( + 'label' => __( 'Mobile phone', 'm365-login' ), + 'target' => 'm365_mobile_phone', + ), + 'streetAddress' => array( + 'label' => __( 'Street address', 'm365-login' ), + 'target' => 'm365_street_address', + ), + 'postalCode' => array( + 'label' => __( 'Postal code', 'm365-login' ), + 'target' => 'm365_postal_code', + ), + 'city' => array( + 'label' => __( 'City', 'm365-login' ), + 'target' => 'm365_city', + ), + 'state' => array( + 'label' => __( 'State / province', 'm365-login' ), + 'target' => 'm365_state', + ), + 'country' => array( + 'label' => __( 'Country', 'm365-login' ), + 'target' => 'm365_country', + ), + 'preferredLanguage' => array( + 'label' => __( 'Language (sets the admin language if installed)', 'm365-login' ), + 'target' => 'locale', + ), + ); + + /** + * Filters the Graph properties offered for the profile sync. + * + * Add entries as 'graphProperty' => array( 'label' => .., 'target' => 'meta_key' ). + * + * @param array $attributes Attributes. + */ + return (array) apply_filters( 'm365_login_sync_attributes', $attributes ); + } + + /** + * Selected attributes that exist in the registry. + * + * @return array Graph property => target. + */ + private function selected_attributes() { + $all = self::attributes(); + $out = array(); + foreach ( (array) $this->settings->get( 'sync_attributes', array() ) as $key ) { + if ( isset( $all[ $key ]['target'] ) ) { + $out[ $key ] = (string) $all[ $key ]['target']; + } + } + return $out; + } + + /* ------------------------------------------------------------------ */ + /* Scheduling */ + /* ------------------------------------------------------------------ */ + + /** + * Schedules the cron event if the sync is enabled but no event is queued. + */ + public function ensure_schedule() { + if ( $this->settings->get( 'sync_enabled' ) && ! wp_next_scheduled( self::CRON_HOOK ) ) { + $this->reschedule(); + } + } + + /** + * (Re)creates or removes the cron event after the settings changed. + */ + public function reschedule() { + $this->settings->flush(); + wp_clear_scheduled_hook( self::CRON_HOOK ); + if ( $this->settings->get( 'sync_enabled' ) ) { + wp_schedule_event( time() + 5 * MINUTE_IN_SECONDS, (string) $this->settings->get( 'sync_interval', 'daily' ), self::CRON_HOOK ); + } + } + + /** + * Removes the cron event (plugin deactivation). + */ + public static function unschedule() { + wp_clear_scheduled_hook( self::CRON_HOOK ); + } + + /** + * Cron callback. + */ + public function run_scheduled() { + if ( $this->settings->get( 'sync_enabled' ) ) { + $this->run( false, 'cron' ); + } + } + + /** + * WP-CLI: synchronise users from Microsoft 365. + * + * ## OPTIONS + * + * [--dry-run] + * : Only report what would change. + * + * ## EXAMPLES + * + * wp m365-login sync --dry-run + * + * @param array $args Positional arguments. + * @param array $assoc_args Flags. + */ + public function cli( $args, $assoc_args ) { + $report = $this->run( ! empty( $assoc_args['dry-run'] ), 'cli' ); + foreach ( $report['log'] as $entry ) { + WP_CLI::log( sprintf( '[%s] %s', $entry['level'], $entry['message'] ) ); + } + foreach ( $report['counts'] as $key => $count ) { + WP_CLI::log( sprintf( '%s: %d', $key, $count ) ); + } + if ( 'ok' === $report['status'] ) { + WP_CLI::success( $report['dry'] ? 'Dry run finished.' : 'Sync finished.' ); + } else { + WP_CLI::error( 'Sync failed or was aborted.' ); + } + } + + /* ------------------------------------------------------------------ */ + /* Run */ + /* ------------------------------------------------------------------ */ + + /** + * Last stored report or null. + * + * @return array|null + */ + public static function last_report() { + $report = get_option( self::REPORT_OPTION, null ); + return is_array( $report ) ? $report : null; + } + + /** + * Runs a full sync. + * + * @param bool $dry Only simulate. + * @param string $trigger 'manual', 'cron' or 'cli'. + * @return array Report. + */ + public function run( $dry = false, $trigger = 'manual' ) { + $this->dry = (bool) $dry; + $this->report = array( + 'started' => time(), + 'finished' => 0, + 'dry' => $this->dry, + 'trigger' => $trigger, + 'status' => 'ok', + 'counts' => array_fill_keys( array( 'created', 'updated', 'linked', 'unchanged', 'disabled', 'enabled', 'deleted', 'photos', 'skipped', 'errors' ), 0 ), + 'log' => array(), + ); + + if ( get_transient( self::LOCK ) ) { + $this->log( 'error', __( 'Another sync is still running. Please try again in a few minutes.', 'm365-login' ) ); + return $this->finish( 'locked', false ); + } + set_transient( self::LOCK, time(), self::LOCK_TTL ); + + if ( function_exists( 'set_time_limit' ) ) { + set_time_limit( 0 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- long-running directory sync. + } + wp_raise_memory_limit( 'admin' ); + require_once ABSPATH . 'wp-admin/includes/user.php'; + + // No "your e-mail/password changed" mails for changes made by the sync. + add_filter( 'send_email_change_email', '__return_false', 99 ); + add_filter( 'send_password_change_email', '__return_false', 99 ); + + try { + $status = $this->sync(); + } finally { + remove_filter( 'send_email_change_email', '__return_false', 99 ); + remove_filter( 'send_password_change_email', '__return_false', 99 ); + delete_transient( self::LOCK ); + } + + return $this->finish( $status, true ); + } + + /** + * Stores and returns the report. + * + * @param string $status 'ok', 'failed', 'aborted' or 'locked'. + * @param bool $store Whether to persist it. + * @return array + */ + private function finish( $status, $store ) { + $this->report['status'] = $status; + $this->report['finished'] = time(); + if ( $store ) { + update_option( self::REPORT_OPTION, $this->report, false ); + } + + /** + * Fires after a user sync run. + * + * @param array $report Report (counts, log, status, dry). + */ + do_action( 'm365_login_sync_finished', $this->report ); + + return $this->report; + } + + /** + * The actual sync. + * + * @return string Status. + */ + private function sync() { + if ( ! $this->settings->is_configured() ) { + $this->log( 'error', __( 'The connection to Microsoft Entra ID is not configured yet.', 'm365-login' ) ); + return 'failed'; + } + if ( $this->settings->is_multi_tenant() ) { + $this->log( 'error', __( 'The user sync needs a pinned tenant ID (GUID) on the Connection tab.', 'm365-login' ) ); + return 'failed'; + } + if ( ! get_role( (string) $this->settings->get( 'sync_default_role' ) ) ) { + $this->log( 'error', __( 'The default role does not exist. Please check the sync settings.', 'm365-login' ) ); + return 'failed'; + } + + // 1. Read the directory. Any error aborts the run before anything is changed. + $select = $this->graph_select(); + $people = $this->fetch_people( $select ); + if ( is_wp_error( $people ) ) { + $this->log( 'error', $this->graph_error_text( $people ) ); + return 'failed'; + } + /* translators: %d: number of users */ + $this->log( 'info', sprintf( _n( '%d user read from Microsoft 365.', '%d users read from Microsoft 365.', count( $people ), 'm365-login' ), count( $people ) ) ); + + $memberships = $this->fetch_role_groups(); + if ( is_wp_error( $memberships ) ) { + $this->log( 'error', $this->graph_error_text( $memberships ) ); + return 'failed'; + } + + // 2. Create, link and update accounts. + $linked = $this->linked_users(); + $seen = array(); + $pending = array(); // Deactivations/deletions, applied after the safety check. + $photos = 0; + $photo_on = array_key_exists( 'photo', $this->selected_attributes() ); + + foreach ( $people as $person ) { + $oid = strtolower( (string) $person['id'] ); + $seen[ $oid ] = true; + + $result = $this->sync_person( $person, $linked, $memberships ); + if ( is_array( $result ) ) { + $pending[] = $result; + } elseif ( $result instanceof WP_User && $photo_on && ! $this->dry ) { + $photos += $this->maybe_sync_photo( $result, $oid, $photos ); + } + } + + // 3. Linked accounts that were not part of the directory listing. + foreach ( $linked as $oid => $user_id ) { + if ( isset( $seen[ $oid ] ) ) { + continue; + } + $action = $this->classify_missing( $oid, $user_id ); + if ( is_wp_error( $action ) ) { + $this->log( 'error', $this->graph_error_text( $action ) ); + return 'failed'; + } + if ( null !== $action ) { + $pending[] = $action; + } + } + + // 4. Safety net: never deactivate or delete a large part of the linked accounts in one go. + $pending = array_values( array_filter( $pending, array( $this, 'is_effective_action' ) ) ); + $limit = (int) apply_filters( 'm365_login_sync_deprovision_limit', max( 5, (int) ceil( count( $linked ) * 0.2 ) ), count( $linked ) ); + if ( count( $pending ) > $limit ) { + $this->log( + 'error', + sprintf( + /* translators: 1: number of accounts, 2: limit */ + __( 'Safety stop: %1$d accounts would be deactivated or deleted, more than the limit of %2$d per run. No account was deactivated or deleted. Check the sync groups and the tenant, then run the sync again (the limit can be changed with the m365_login_sync_deprovision_limit filter).', 'm365-login' ), + count( $pending ), + $limit + ) + ); + return 'aborted'; + } + foreach ( $pending as $action ) { + $this->apply_action( $action ); + } + + return 'ok'; + } + + /** + * Graph properties to read for every user. + * + * @return string[] + */ + private function graph_select() { + $select = array( 'id', 'accountEnabled', 'mail', 'userPrincipalName', 'userType', 'displayName' ); + foreach ( array_keys( $this->selected_attributes() ) as $key ) { + if ( 'photo' !== $key && preg_match( '/^[A-Za-z]+$/', $key ) ) { + $select[] = $key; + } + } + return array_values( array_unique( $select ) ); + } + + /** + * Users in scope: the whole tenant or the (nested) members of the sync groups. + * + * @param string[] $select Properties. + * @return array[]|WP_Error + */ + private function fetch_people( $select ) { + $groups = $this->settings->sync_scope_groups(); + if ( empty( $groups ) ) { + return $this->graph->list_users( $select ); + } + $people = array(); + foreach ( $groups as $group_id => $name ) { + $members = $this->graph->list_group_users( $group_id, $select ); + if ( is_wp_error( $members ) ) { + return $members; + } + foreach ( $members as $member ) { + $people[ strtolower( (string) $member['id'] ) ] = $member; + } + } + return array_values( $people ); + } + + /** + * Members (object IDs) of every group used in the role mapping. + * + * @return array|WP_Error group ID => array( oid => true ). + */ + private function fetch_role_groups() { + $out = array(); + foreach ( array_keys( $this->settings->sync_role_map() ) as $group_id ) { + $members = $this->graph->list_group_users( $group_id, array( 'id' ) ); + if ( is_wp_error( $members ) ) { + return $members; + } + $out[ $group_id ] = array(); + foreach ( $members as $member ) { + $out[ $group_id ][ strtolower( (string) $member['id'] ) ] = true; + } + } + return $out; + } + + /** + * WordPress users linked to a Microsoft object ID (this site only). + * + * @return array oid => user ID. + */ + private function linked_users() { + $users = get_users( + array( + 'meta_key' => M365_Login_Auth::META_OID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + 'meta_compare' => 'EXISTS', + 'fields' => array( 'ID' ), + 'number' => -1, + ) + ); + $out = array(); + foreach ( $users as $row ) { + $oid = strtolower( (string) get_user_meta( (int) $row->ID, M365_Login_Auth::META_OID, true ) ); + if ( M365_Login_Settings::is_guid( $oid ) ) { + $out[ $oid ] = (int) $row->ID; + } + } + return $out; + } + + /** + * Creates, links or updates the account of one directory user. + * + * @param array $person Graph user. + * @param array $linked oid => user ID (updated when an account is linked or created). + * @param array $memberships Role group memberships. + * @return WP_User|array|null The synced account, a pending deprovision action, or null when skipped. + */ + private function sync_person( $person, &$linked, $memberships ) { + $oid = strtolower( (string) $person['id'] ); + $upn = isset( $person['userPrincipalName'] ) ? (string) $person['userPrincipalName'] : $oid; + $enabled = ! isset( $person['accountEnabled'] ) || false !== $person['accountEnabled']; + + if ( ! M365_Login_Settings::is_guid( $oid ) ) { + return null; + } + + $user = isset( $linked[ $oid ] ) ? get_userdata( $linked[ $oid ] ) : false; + + if ( ! $user && isset( $person['userType'] ) && 'Guest' === $person['userType'] && ! $this->settings->get( 'sync_guests' ) ) { + return null; // Guests are not imported (they may still be linked through a sign-in). + } + + $email = $this->email_of( $person ); + if ( '' === $email ) { + if ( ! $user ) { + /* translators: %s: user principal name */ + $this->skip( sprintf( __( '%s: no usable e-mail address, skipped.', 'm365-login' ), $upn ) ); + } + return $user ? $user : null; + } + if ( ! $this->domain_allowed( $email ) ) { + if ( ! $user ) { + /* translators: %s: e-mail address */ + $this->skip( sprintf( __( '%s: e-mail domain is not on the allow-list, skipped.', 'm365-login' ), $email ) ); + } + return null; + } + + // Not linked yet: match an existing account by e-mail address. + if ( ! $user ) { + $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 ) { + /* translators: %s: e-mail address */ + $this->skip( sprintf( __( '%s: the WordPress account with this e-mail address is linked to a different Microsoft account, skipped.', 'm365-login' ), $email ) ); + return null; + } + $user = $by_mail; + $this->log( 'info', sprintf( /* translators: %s: e-mail address */ __( '%s: existing account linked.', 'm365-login' ), $email ) ); + $this->count( 'linked' ); + if ( ! $this->dry ) { + update_user_meta( $user->ID, M365_Login_Auth::META_OID, $oid ); + } + $linked[ $oid ] = $user->ID; + } + } + + // Disabled in Microsoft 365. + if ( ! $enabled ) { + if ( ! $user ) { + return null; // Nothing to create for disabled people. + } + return $this->action( (string) $this->settings->get( 'sync_disabled_action' ), $user, 'disabled', __( 'disabled in Microsoft 365', 'm365-login' ) ); + } + + if ( ! $user ) { + return $this->create_user( $person, $oid, $email, $linked, $memberships ); + } + + if ( is_multisite() && ! is_user_member_of_blog( $user->ID ) ) { + if ( ! $this->dry ) { + add_user_to_blog( get_current_blog_id(), $user->ID, (string) $this->settings->get( 'sync_default_role' ) ); + } + $this->log( 'info', sprintf( /* translators: %s: e-mail address */ __( '%s: added to this site.', 'm365-login' ), $email ) ); + } + + // Accounts deactivated by the sync come back when the person is active again. + $disabled = self::disabled_info( $user->ID ); + if ( $disabled && 'sync' === $disabled['by'] ) { + if ( ! $this->dry ) { + self::enable( $user->ID ); + } + $this->log( 'info', sprintf( /* translators: %s: e-mail address */ __( '%s: reactivated (active in Microsoft 365 again).', 'm365-login' ), $email ) ); + $this->count( 'enabled' ); + } + + $changes = $this->update_profile( $user, $person, $email ); + if ( $this->manages_roles( $user ) ) { + $changes = array_merge( $changes, $this->update_roles( $user, $this->desired_roles( $oid, $memberships ) ) ); + } + + if ( $changes ) { + /* translators: 1: e-mail address, 2: list of changed fields */ + $this->log( 'info', sprintf( __( '%1$s: updated (%2$s).', 'm365-login' ), $email, implode( ', ', $changes ) ) ); + $this->count( 'updated' ); + } else { + $this->count( 'unchanged' ); + } + if ( ! $this->dry ) { + update_user_meta( $user->ID, self::META_LAST_SYNC, time() ); + } + + return $user; + } + + /** + * Creates a new WordPress account for a directory user. + * + * @param array $person Graph user. + * @param string $oid Object ID. + * @param string $email E-mail address. + * @param array $linked oid => user ID. + * @param array $memberships Role group memberships. + * @return WP_User|null + */ + private function create_user( $person, $oid, $email, &$linked, $memberships ) { + $roles = $this->desired_roles( $oid, $memberships ); + + /* translators: 1: e-mail address, 2: role names */ + $this->log( 'info', sprintf( __( '%1$s: account created (%2$s).', 'm365-login' ), $email, $this->role_names( $roles ) ) ); + $this->count( 'created' ); + if ( $this->dry ) { + return null; + } + + $data = array( + 'user_login' => $this->unique_login( $email ), + 'user_email' => $email, + 'user_pass' => wp_generate_password( 40, true, true ), + 'role' => $roles[0], + 'display_name' => ! empty( $person['displayName'] ) ? sanitize_text_field( (string) $person['displayName'] ) : $email, + ); + + /** + * Filters the data used to create a WordPress account for a Microsoft 365 user. + * + * @param array $data Arguments for wp_insert_user(). + * @param array $person Graph user object. + */ + $data = apply_filters( 'm365_login_sync_new_user_data', $data, $person ); + + $user_id = wp_insert_user( $data ); + if ( is_wp_error( $user_id ) ) { + $this->count( 'created', -1 ); + $this->count( 'errors' ); + /* translators: 1: e-mail address, 2: error message */ + $this->log( 'error', sprintf( __( '%1$s: account could not be created: %2$s', 'm365-login' ), $email, $user_id->get_error_message() ) ); + return null; + } + + update_user_meta( $user_id, M365_Login_Auth::META_OID, $oid ); + update_user_meta( $user_id, self::META_SYNCED, time() ); + update_user_meta( $user_id, self::META_LAST_SYNC, time() ); + $linked[ $oid ] = (int) $user_id; + + $user = get_userdata( $user_id ); + $this->update_profile( $user, $person, $email ); + $this->update_roles( $user, $roles ); + + /** + * Fires after the sync created a WordPress account. + * + * @param WP_User $user New user. + * @param array $person Graph user object. + */ + do_action( 'm365_login_sync_user_created', $user, $person ); + + return $user; + } + + /** + * Unique user_login derived from the e-mail address. + * + * @param string $email E-mail address. + * @return string + */ + private function unique_login( $email ) { + $base = sanitize_user( strtok( $email, '@' ), true ); + $base = '' === $base ? 'm365user' : mb_substr( $base, 0, 50 ); + $login = $base; + $suffix = 2; + while ( username_exists( $login ) ) { + $login = $base . $suffix; + ++$suffix; + } + return $login; + } + + /** + * Copies e-mail address and selected attributes into the profile. + * + * @param WP_User $user User. + * @param array $person Graph user. + * @param string $email E-mail from the directory. + * @return string[] Changed fields (for the log). + */ + private function update_profile( $user, $person, $email ) { + $changes = array(); + $fields = array(); + + if ( strtolower( $user->user_email ) !== $email ) { + $owner = get_user_by( 'email', $email ); + if ( $owner && $owner->ID !== $user->ID ) { + /* translators: %s: e-mail address */ + $this->log( 'warning', sprintf( __( '%s: e-mail address is used by another WordPress account and was not changed.', 'm365-login' ), $email ) ); + } else { + $fields['user_email'] = $email; + $changes[] = __( 'e-mail', 'm365-login' ); + } + } + + $labels = self::attributes(); + foreach ( $this->selected_attributes() as $key => $target ) { + if ( 'avatar' === $target ) { + continue; + } + $value = $this->attribute_value( $person, $key, $target ); + if ( null === $value ) { + continue; + } + + if ( in_array( $target, array( 'display_name', 'first_name', 'last_name', 'locale' ), true ) ) { + if ( '' === $value && 'display_name' === $target ) { + continue; + } + if ( (string) $user->$target !== $value ) { + $fields[ $target ] = $value; + $changes[] = $labels[ $key ]['label']; + } + continue; + } + + $meta_key = sanitize_key( $target ); + if ( (string) get_user_meta( $user->ID, $meta_key, true ) !== $value ) { + if ( ! $this->dry ) { + if ( '' === $value ) { + delete_user_meta( $user->ID, $meta_key ); + } else { + update_user_meta( $user->ID, $meta_key, $value ); + } + } + $changes[] = $labels[ $key ]['label']; + } + } + + if ( $fields && ! $this->dry ) { + $fields['ID'] = $user->ID; + $result = wp_update_user( $fields ); + if ( is_wp_error( $result ) ) { + $this->count( 'errors' ); + /* translators: 1: e-mail address, 2: error message */ + $this->log( 'error', sprintf( __( '%1$s: profile could not be updated: %2$s', 'm365-login' ), $email, $result->get_error_message() ) ); + return array(); + } + clean_user_cache( $user->ID ); + } + + return $changes; + } + + /** + * Normalised value of one attribute, or null to leave the field alone. + * + * @param array $person Graph user. + * @param string $key Graph property. + * @param string $target Target field. + * @return string|null + */ + private function attribute_value( $person, $key, $target ) { + if ( ! array_key_exists( $key, $person ) ) { + return null; + } + $raw = $person[ $key ]; + if ( is_array( $raw ) ) { + $raw = isset( $raw[0] ) && is_scalar( $raw[0] ) ? $raw[0] : ''; + } + $value = null === $raw ? '' : sanitize_text_field( (string) $raw ); + + if ( 'locale' === $target ) { + $locale = str_replace( '-', '_', $value ); + if ( '' === $locale ) { + return null; + } + if ( 'en_US' !== $locale && ! in_array( $locale, get_available_languages(), true ) ) { + return null; // Language pack not installed: keep the site default. + } + return $locale; + } + return mb_substr( $value, 0, 250 ); + } + + /** + * Roles a person should have, in order (the first one is the primary role). + * + * @param string $oid Object ID. + * @param array $memberships Role group memberships. + * @return string[] + */ + private function desired_roles( $oid, $memberships ) { + $default = (string) $this->settings->get( 'sync_default_role' ); + $mapped = array(); + foreach ( $this->settings->sync_role_map() as $group_id => $row ) { + if ( isset( $memberships[ $group_id ][ $oid ] ) && get_role( $row['role'] ) ) { + $mapped[] = $row['role']; + } + } + $mapped = array_values( array_unique( $mapped ) ); + + if ( 'replace' === $this->settings->get( 'sync_role_mode' ) ) { + $roles = $mapped ? array( $mapped[0] ) : array( $default ); + } else { + $roles = array_values( array_unique( array_merge( array( $default ), $mapped ) ) ); + } + + /** + * Filters the WordPress roles the sync assigns to a Microsoft 365 user. + * + * @param string[] $roles Role slugs, the first is the primary role. + * @param string $oid Microsoft object ID. + */ + $roles = array_values( array_filter( (array) apply_filters( 'm365_login_sync_roles', $roles, $oid ), 'get_role' ) ); + return $roles ? $roles : array( $default ); + } + + /** + * Whether the sync may change the roles of this account. + * + * @param WP_User $user User. + * @return bool + */ + private function manages_roles( $user ) { + if ( $this->is_protected( $user ) ) { + return false; + } + return (bool) get_user_meta( $user->ID, self::META_SYNCED, true ) || (bool) $this->settings->get( 'sync_manage_existing' ); + } + + /** + * Applies the desired roles. + * + * @param WP_User $user User. + * @param string[] $roles Desired roles. + * @return string[] Changes for the log. + */ + private function update_roles( $user, $roles ) { + $current = array_values( $user->roles ); + $same = count( $current ) === count( $roles ) && ! array_diff( $current, $roles ) && reset( $current ) === $roles[0]; + if ( $same ) { + return array(); + } + if ( ! $this->dry ) { + $user->set_role( $roles[0] ); + foreach ( array_slice( $roles, 1 ) as $role ) { + $user->add_role( $role ); + } + } + /* translators: %s: role names */ + return array( sprintf( __( 'roles: %s', 'm365-login' ), $this->role_names( $roles ) ) ); + } + + /** + * Human readable role list. + * + * @param string[] $roles Role slugs. + * @return string + */ + private function role_names( $roles ) { + $names = wp_roles()->get_names(); + $out = array(); + foreach ( $roles as $role ) { + $out[] = isset( $names[ $role ] ) ? translate_user_role( $names[ $role ] ) : $role; + } + return implode( ', ', $out ); + } + + /** + * Decides what happens to a linked account that was not in the directory listing. + * + * @param string $oid Object ID. + * @param int $user_id User ID. + * @return array|null|WP_Error Pending action, null for none. + */ + private function classify_missing( $oid, $user_id ) { + $user = get_userdata( $user_id ); + if ( ! $user ) { + return null; + } + + // Double-check with Graph: only a 404 proves that the account was deleted. + $person = $this->graph->get_user( $oid, array( 'id', 'accountEnabled', 'userType' ) ); + if ( is_wp_error( $person ) ) { + if ( M365_Login_Graph::is_not_found( $person ) ) { + return $this->action( (string) $this->settings->get( 'sync_deleted_action' ), $user, 'deleted', __( 'deleted in Microsoft 365', 'm365-login' ) ); + } + return $person; + } + if ( isset( $person['accountEnabled'] ) && false === $person['accountEnabled'] ) { + return $this->action( (string) $this->settings->get( 'sync_disabled_action' ), $user, 'disabled', __( 'disabled in Microsoft 365', 'm365-login' ) ); + } + if ( $this->settings->sync_scope_groups() && ( ! isset( $person['userType'] ) || 'Guest' !== $person['userType'] || $this->settings->get( 'sync_guests' ) ) ) { + return $this->action( (string) $this->settings->get( 'sync_scope_action' ), $user, 'scope', __( 'no longer a member of the sync groups', 'm365-login' ) ); + } + return null; + } + + /** + * Builds a pending deprovision action. + * + * @param string $what 'none', 'disable' or 'delete'. + * @param WP_User $user User. + * @param string $reason Machine reason. + * @param string $label Human reason. + * @return array|null + */ + private function action( $what, $user, $reason, $label ) { + if ( ! in_array( $what, array( 'disable', 'delete' ), true ) ) { + return null; + } + if ( $this->is_protected( $user ) ) { + /* translators: 1: e-mail address, 2: reason */ + $this->skip( sprintf( __( '%1$s: %2$s, but the account is protected (administrator or your own account) and was not changed.', 'm365-login' ), $user->user_email, $label ) ); + return null; + } + return array( + 'what' => $what, + 'user' => $user, + 'reason' => $reason, + 'label' => $label, + ); + } + + /** + * Filters out actions that would not change anything (already deactivated). + * + * @param array $action Pending action. + * @return bool + */ + private function is_effective_action( $action ) { + return 'delete' === $action['what'] || ! self::disabled_info( $action['user']->ID ); + } + + /** + * Deactivates or deletes an account. + * + * @param array $action Pending action. + */ + private function apply_action( $action ) { + $user = $action['user']; + $reassign = (int) $this->settings->get( 'sync_reassign' ); + $what = $action['what']; + + if ( 'delete' === $what && ( ! $reassign || $reassign === $user->ID || ! get_userdata( $reassign ) ) ) { + /* translators: %s: e-mail address */ + $this->log( 'warning', sprintf( __( '%s: no valid user to receive the content is selected, so the account is deactivated instead of deleted.', 'm365-login' ), $user->user_email ) ); + $what = 'disable'; + if ( self::disabled_info( $user->ID ) ) { + return; + } + } + + if ( 'delete' === $what ) { + /* translators: 1: e-mail address, 2: reason */ + $this->log( 'info', sprintf( __( '%1$s: account deleted (%2$s).', 'm365-login' ), $user->user_email, $action['label'] ) ); + $this->count( 'deleted' ); + if ( ! $this->dry ) { + $this->delete_photo( $user->ID ); + wp_delete_user( $user->ID, $reassign ); + } + return; + } + + /* translators: 1: e-mail address, 2: reason */ + $this->log( 'info', sprintf( __( '%1$s: account deactivated (%2$s).', 'm365-login' ), $user->user_email, $action['label'] ) ); + $this->count( 'disabled' ); + if ( ! $this->dry ) { + self::disable( $user->ID, 'sync', $action['reason'] ); + } + } + + /** + * Accounts the sync never deactivates, deletes or re-roles. + * + * Administrators that existed before the sync are protected; accounts the sync + * created (and may have promoted through a group mapping) are fully managed. + * + * @param WP_User $user User. + * @return bool + */ + private function is_protected( $user ) { + $protected = get_current_user_id() === $user->ID + || ( is_multisite() && is_super_admin( $user->ID ) ) + || ( ! get_user_meta( $user->ID, self::META_SYNCED, true ) && user_can( $user, 'manage_options' ) ); + + /** + * Filters whether the sync must leave an account alone (no role changes, deactivation or deletion). + * + * @param bool $protected Whether the account is protected. + * @param WP_User $user User. + */ + return (bool) apply_filters( 'm365_login_sync_protect_user', $protected, $user ); + } + + /** + * E-mail address of a directory user (mail, else a usable UPN). + * + * @param array $person Graph user. + * @return string Lowercase address or ''. + */ + private function email_of( $person ) { + $candidates = array(); + if ( ! empty( $person['mail'] ) ) { + $candidates[] = (string) $person['mail']; + } + if ( ! empty( $person['userPrincipalName'] ) && false === stripos( (string) $person['userPrincipalName'], '#ext#' ) ) { + $candidates[] = (string) $person['userPrincipalName']; + } + foreach ( $candidates as $candidate ) { + $candidate = strtolower( trim( $candidate ) ); + if ( is_email( $candidate ) ) { + /** + * Filters the e-mail address the sync uses for a Microsoft 365 user. + * + * @param string $email Address. + * @param array $person Graph user object. + */ + return strtolower( (string) apply_filters( 'm365_login_sync_email', $candidate, $person ) ); + } + } + return ''; + } + + /** + * Domain allow-list from the Security tab. + * + * @param string $email E-mail. + * @return bool + */ + private function domain_allowed( $email ) { + $allowed = $this->settings->allowed_domains(); + return empty( $allowed ) || in_array( strtolower( substr( strrchr( $email, '@' ), 1 ) ), $allowed, true ); + } + + /** + * Friendlier text for common Graph permission errors. + * + * @param WP_Error $error Error. + * @return string + */ + private function graph_error_text( $error ) { + $message = $error->get_error_message(); + if ( false !== stripos( $message, 'Authorization_RequestDenied' ) || false !== stripos( $message, 'Insufficient privileges' ) ) { + return __( 'Microsoft Graph refused the request. Grant the application permissions "User.Read.All" and "GroupMember.Read.All" with admin consent in Entra ID.', 'm365-login' ); + } + /* translators: %s: error message */ + return sprintf( __( 'Microsoft Graph error: %s', 'm365-login' ), $message ); + } + + /** + * Adds a log line. + * + * @param string $level 'info', 'warning' or 'error'. + * @param string $message Message. + */ + private function log( $level, $message ) { + if ( count( $this->report['log'] ) < self::LOG_LIMIT ) { + $this->report['log'][] = array( + 'level' => $level, + 'message' => $message, + ); + } elseif ( count( $this->report['log'] ) === self::LOG_LIMIT ) { + $this->report['log'][] = array( + 'level' => 'warning', + 'message' => __( 'Log truncated.', 'm365-login' ), + ); + } + } + + /** + * Logs a skipped person. + * + * @param string $message Message. + */ + private function skip( $message ) { + $this->log( 'warning', $message ); + $this->count( 'skipped' ); + } + + /** + * Increments a counter. + * + * @param string $key Counter. + * @param int $delta Amount. + */ + private function count( $key, $delta = 1 ) { + $this->report['counts'][ $key ] += $delta; + } + + /* ------------------------------------------------------------------ */ + /* Profile photos */ + /* ------------------------------------------------------------------ */ + + /** + * Refreshes the profile photo when it was not checked recently. + * + * @param WP_User $user User. + * @param string $oid Object ID. + * @param int $done_so_far Photo checks done in this run. + * @return int 1 when Graph was asked, 0 otherwise. + */ + private function maybe_sync_photo( $user, $oid, $done_so_far ) { + /** + * Maximum number of profile photo checks per sync run (the rest follows in later runs). + * + * @param int $limit Limit. + */ + if ( $done_so_far >= (int) apply_filters( 'm365_login_sync_photo_limit', 200 ) ) { + return 0; + } + $stored = get_user_meta( $user->ID, self::META_PHOTO, true ); + $stored = is_array( $stored ) ? $stored : array(); + + /** + * Seconds between two photo checks of the same user. + * + * @param int $interval Interval. + */ + $interval = (int) apply_filters( 'm365_login_sync_photo_interval', 20 * HOUR_IN_SECONDS ); + if ( ! empty( $stored['checked'] ) && time() - (int) $stored['checked'] < $interval ) { + return 0; + } + + $info = $this->graph->photo_info( $oid ); + if ( is_wp_error( $info ) ) { + /* translators: 1: e-mail address, 2: error message */ + $this->log( 'warning', sprintf( __( '%1$s: profile photo could not be read: %2$s', 'm365-login' ), $user->user_email, $info->get_error_message() ) ); + return 1; + } + + if ( null === $info ) { + if ( ! empty( $stored['file'] ) ) { + $this->delete_photo( $user->ID ); + /* translators: %s: e-mail address */ + $this->log( 'info', sprintf( __( '%s: profile photo removed.', 'm365-login' ), $user->user_email ) ); + $this->count( 'photos' ); + } + update_user_meta( $user->ID, self::META_PHOTO, array( 'checked' => time() ) ); + return 1; + } + + if ( ! empty( $stored['file'] ) && isset( $stored['etag'] ) && $stored['etag'] === $info['etag'] && file_exists( self::photo_path( $stored['file'] ) ) ) { + $stored['checked'] = time(); + update_user_meta( $user->ID, self::META_PHOTO, $stored ); + return 1; + } + + $bytes = $this->graph->photo_bytes( $info['path'] ); + if ( is_wp_error( $bytes ) || '' === $bytes || strlen( $bytes ) > self::PHOTO_MAX ) { + /* translators: %s: e-mail address */ + $this->log( 'warning', sprintf( __( '%s: profile photo could not be downloaded.', 'm365-login' ), $user->user_email ) ); + return 1; + } + + $file = $this->store_photo( $user->ID, $oid, $info['etag'], $bytes ); + if ( '' === $file ) { + /* translators: %s: e-mail address */ + $this->log( 'warning', sprintf( __( '%s: profile photo is not a valid image or could not be saved.', 'm365-login' ), $user->user_email ) ); + return 1; + } + + if ( ! empty( $stored['file'] ) && $stored['file'] !== $file ) { + wp_delete_file( self::photo_path( $stored['file'] ) ); + } + update_user_meta( + $user->ID, + self::META_PHOTO, + array( + 'file' => $file, + 'etag' => $info['etag'], + 'checked' => time(), + ) + ); + /* translators: %s: e-mail address */ + $this->log( 'info', sprintf( __( '%s: profile photo updated.', 'm365-login' ), $user->user_email ) ); + $this->count( 'photos' ); + return 1; + } + + /** + * Writes the image into uploads/m365-login-avatars/. + * + * @param int $user_id User ID. + * @param string $oid Object ID. + * @param string $etag Photo version. + * @param string $bytes Image data. + * @return string File path relative to the uploads base directory, or ''. + */ + private function store_photo( $user_id, $oid, $etag, $bytes ) { + $size = @getimagesizefromstring( $bytes ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- invalid data is expected to fail quietly. + $exts = array( + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + ); + if ( ! is_array( $size ) || empty( $size['mime'] ) || ! isset( $exts[ $size['mime'] ] ) ) { + return ''; + } + + $name = 'm365-' . substr( wp_hash( $oid . '|avatar' ), 0, 16 ) . '-' . substr( md5( $etag ), 0, 8 ) . '.' . $exts[ $size['mime'] ]; + $subdir = static function ( $dirs ) { + $dirs['subdir'] = '/' . self::PHOTO_DIR; + $dirs['path'] = $dirs['basedir'] . $dirs['subdir']; + $dirs['url'] = $dirs['baseurl'] . $dirs['subdir']; + return $dirs; + }; + + add_filter( 'upload_dir', $subdir ); + $existing = wp_upload_dir(); + if ( file_exists( trailingslashit( $existing['path'] ) . $name ) ) { + wp_delete_file( trailingslashit( $existing['path'] ) . $name ); + } + $upload = wp_upload_bits( $name, null, $bytes ); + remove_filter( 'upload_dir', $subdir ); + + if ( ! empty( $upload['error'] ) || empty( $upload['file'] ) ) { + return ''; + } + $uploads = wp_get_upload_dir(); + return ltrim( str_replace( wp_normalize_path( $uploads['basedir'] ), '', wp_normalize_path( $upload['file'] ) ), '/' ); + } + + /** + * Absolute path of a stored photo. + * + * @param string $file Relative path. + * @return string + */ + private static function photo_path( $file ) { + $uploads = wp_get_upload_dir(); + return trailingslashit( $uploads['basedir'] ) . ltrim( $file, '/' ); + } + + /** + * Deletes a user's stored photo. + * + * @param int $user_id User ID. + */ + private function delete_photo( $user_id ) { + $stored = get_user_meta( $user_id, self::META_PHOTO, true ); + if ( is_array( $stored ) && ! empty( $stored['file'] ) && 0 === strpos( $stored['file'], self::PHOTO_DIR . '/' ) ) { + wp_delete_file( self::photo_path( $stored['file'] ) ); + } + delete_user_meta( $user_id, self::META_PHOTO ); + } + + /** + * Uses the synced Microsoft 365 photo as avatar. + * + * @param array $args Avatar data. + * @param mixed $id_or_email User ID, e-mail, WP_User, WP_Post or WP_Comment. + * @return array + */ + public function avatar_data( $args, $id_or_email ) { + if ( ! in_array( 'photo', (array) $this->settings->get( 'sync_attributes', array() ), true ) ) { + return $args; + } + + $user_id = 0; + if ( is_numeric( $id_or_email ) ) { + $user_id = (int) $id_or_email; + } elseif ( $id_or_email instanceof WP_User ) { + $user_id = $id_or_email->ID; + } elseif ( $id_or_email instanceof WP_Post ) { + $user_id = (int) $id_or_email->post_author; + } elseif ( $id_or_email instanceof WP_Comment ) { + $user_id = (int) $id_or_email->user_id; + } elseif ( is_string( $id_or_email ) && is_email( $id_or_email ) ) { + $user = get_user_by( 'email', $id_or_email ); + $user_id = $user ? $user->ID : 0; + } + if ( ! $user_id ) { + return $args; + } + + $stored = get_user_meta( $user_id, self::META_PHOTO, true ); + if ( ! is_array( $stored ) || empty( $stored['file'] ) ) { + return $args; + } + $uploads = wp_get_upload_dir(); + $args['url'] = trailingslashit( $uploads['baseurl'] ) . ltrim( $stored['file'], '/' ); + $args['found_avatar'] = true; + return $args; + } + + /* ------------------------------------------------------------------ */ + /* Deactivated accounts */ + /* ------------------------------------------------------------------ */ + + /** + * Deactivation details or null when the account is active. + * + * @param int $user_id User ID. + * @return array|null + */ + public static function disabled_info( $user_id ) { + $info = get_user_meta( (int) $user_id, self::META_DISABLED, true ); + if ( ! is_array( $info ) || empty( $info['time'] ) ) { + return null; + } + return wp_parse_args( + $info, + array( + 'by' => 'manual', + 'reason' => '', + ) + ); + } + + /** + * Deactivates an account and ends all of its sessions. + * + * @param int $user_id User ID. + * @param string $by 'sync' or 'manual'. + * @param string $reason Machine reason. + */ + public static function disable( $user_id, $by, $reason = '' ) { + update_user_meta( + $user_id, + self::META_DISABLED, + array( + 'time' => time(), + 'by' => $by, + 'reason' => $reason, + ) + ); + WP_Session_Tokens::get_instance( $user_id )->destroy_all(); + + /** + * Fires after an account was deactivated. + * + * @param int $user_id User ID. + * @param string $by 'sync' or 'manual'. + * @param string $reason 'disabled', 'deleted', 'scope' or ''. + */ + do_action( 'm365_login_user_disabled', $user_id, $by, $reason ); + } + + /** + * Reactivates an account. + * + * @param int $user_id User ID. + */ + public static function enable( $user_id ) { + delete_user_meta( $user_id, self::META_DISABLED ); + + /** + * Fires after an account was reactivated. + * + * @param int $user_id User ID. + */ + do_action( 'm365_login_user_enabled', $user_id ); + } + + /** + * Refuses every sign-in (password, application password, Microsoft) of deactivated accounts. + * + * @param null|WP_User|WP_Error $user Result so far. + * @return null|WP_User|WP_Error + */ + public function block_disabled_login( $user ) { + if ( $user instanceof WP_User && self::disabled_info( $user->ID ) ) { + return new WP_Error( 'm365_login_disabled', __( 'This account has been deactivated.', 'm365-login' ) ); + } + return $user; + } + + /** + * Treats existing sessions of deactivated accounts as logged out. + * + * @param int|false $user_id Detected user. + * @return int|false + */ + public function drop_disabled_session( $user_id ) { + if ( $user_id && self::disabled_info( (int) $user_id ) ) { + return false; + } + return $user_id; + } + + /* ------------------------------------------------------------------ */ + /* Users screen and profile */ + /* ------------------------------------------------------------------ */ + + /** + * Adds the "Microsoft 365" column to the users list. + * + * @param string[] $columns Columns. + * @return string[] + */ + public function users_column( $columns ) { + $columns['m365_login'] = __( 'Microsoft 365', 'm365-login' ); + return $columns; + } + + /** + * Renders the "Microsoft 365" column. + * + * @param string $output Output so far. + * @param string $column Column. + * @param int $user_id User ID. + * @return string + */ + public function users_column_value( $output, $column, $user_id ) { + if ( 'm365_login' !== $column ) { + return $output; + } + $parts = array(); + if ( self::disabled_info( $user_id ) ) { + $parts[] = '' . esc_html__( 'Deactivated', 'm365-login' ) . ''; + } + if ( get_user_meta( $user_id, self::META_SYNCED, true ) ) { + $parts[] = esc_html__( 'Imported', 'm365-login' ); + } elseif ( get_user_meta( $user_id, M365_Login_Auth::META_OID, true ) ) { + $parts[] = esc_html__( 'Linked', 'm365-login' ); + } + return $parts ? implode( '
    ', $parts ) : '—'; + } + + /** + * "Deactivate" / "Reactivate" row actions. + * + * @param string[] $actions Actions. + * @param WP_User $user User. + * @return string[] + */ + public function user_row_actions( $actions, $user ) { + if ( ! current_user_can( 'edit_user', $user->ID ) || get_current_user_id() === $user->ID ) { + return $actions; + } + $disabled = (bool) self::disabled_info( $user->ID ); + $url = wp_nonce_url( + add_query_arg( + array( + 'action' => self::POST_STATE, + 'user_id' => $user->ID, + 'state' => $disabled ? 'enable' : 'disable', + ), + admin_url( 'admin-post.php' ) + ), + self::POST_STATE . '_' . $user->ID + ); + $actions['m365_login_state'] = '' . ( $disabled ? esc_html__( 'Reactivate', 'm365-login' ) : esc_html__( 'Deactivate', 'm365-login' ) ) . ''; + return $actions; + } + + /** + * Handles the row actions. + */ + public function handle_user_state() { + $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0; + check_admin_referer( self::POST_STATE . '_' . $user_id ); + if ( ! $user_id || ! current_user_can( 'edit_user', $user_id ) || get_current_user_id() === $user_id ) { + wp_die( esc_html__( 'You are not allowed to do this.', 'm365-login' ), 403 ); + } + $state = isset( $_GET['state'] ) ? sanitize_key( wp_unslash( $_GET['state'] ) ) : ''; + if ( 'disable' === $state ) { + self::disable( $user_id, 'manual' ); + } else { + self::enable( $user_id ); + } + wp_safe_redirect( add_query_arg( 'm365_user_state', 'disable' === $state ? 'disabled' : 'enabled', admin_url( 'users.php' ) ) ); + exit; + } + + /** + * Confirmation after a row action. + */ + public function user_state_notice() { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only. + $state = isset( $_GET['m365_user_state'] ) ? sanitize_key( wp_unslash( $_GET['m365_user_state'] ) ) : ''; + if ( '' === $state ) { + return; + } + $text = 'disabled' === $state + ? __( 'The account has been deactivated and signed out everywhere.', 'm365-login' ) + : __( 'The account has been reactivated.', 'm365-login' ); + printf( '

    %s

    ', esc_html( $text ) ); + } + + /** + * Read-only "Microsoft 365" section on the profile screen. + * + * @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 ) ) { + return; + } + $rows = array(); + $disabled = self::disabled_info( $user->ID ); + if ( $disabled ) { + $reasons = array( + 'disabled' => __( 'disabled in Microsoft 365', 'm365-login' ), + 'deleted' => __( 'deleted in Microsoft 365', 'm365-login' ), + 'scope' => __( 'no longer a member of the sync groups', 'm365-login' ), + ); + $text = sprintf( + /* translators: 1: date, 2: reason */ + __( 'Deactivated since %1$s (%2$s)', 'm365-login' ), + wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), (int) $disabled['time'] ), + isset( $reasons[ $disabled['reason'] ] ) ? $reasons[ $disabled['reason'] ] : __( 'manually', 'm365-login' ) + ); + $rows[ __( 'Status', 'm365-login' ) ] = $text; + } + if ( '' !== $oid ) { + $rows[ __( 'Object ID', 'm365-login' ) ] = $oid; + } + $last = (int) get_user_meta( $user->ID, self::META_LAST_SYNC, true ); + if ( $last ) { + $rows[ __( 'Last sync', 'm365-login' ) ] = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $last ); + } + foreach ( self::attributes() as $attribute ) { + $target = (string) $attribute['target']; + if ( 0 !== strpos( $target, 'm365_' ) ) { + continue; + } + $value = (string) get_user_meta( $user->ID, $target, true ); + if ( '' !== $value ) { + $rows[ (string) $attribute['label'] ] = $value; + } + } + ?> +

    + + $value ) : ?> + + + + + + +

    + settings = new M365_Login_Settings(); $this->graph = new M365_Login_Graph( $this->settings ); $this->auth = new M365_Login_Auth( $this->settings, $this->graph ); + $this->sync = new M365_Login_Sync( $this->settings, $this->graph ); $this->button = new M365_Login_Button( $this->settings ); if ( is_admin() ) { - $this->admin = new M365_Login_Admin( $this->settings, $this->auth, $this->graph ); + $this->admin = new M365_Login_Admin( $this->settings, $this->auth, $this->graph, $this->sync ); } add_filter( 'plugin_action_links_' . plugin_basename( M365_LOGIN_FILE ), array( $this, 'action_links' ) ); diff --git a/languages/m365-login-de_DE.mo b/languages/m365-login-de_DE.mo index 36e39e263882cc9d7f60b69252c116669296e58e..dab20df0a1b57c0d8aac96989fc0a58243cbb6af 100644 GIT binary patch delta 21451 zcma)@37i~No%r8`5ONVhLhg`MPG-nV=YRk?FqvU;K@x@>gg_wFOxH|TrZ2jyXEF&= z;S>*+qXZR!K#T~Y9JT^0hsTJn%7TEpD$2S+cGq1I-Tmvb3cBCldsW@lGlai<@|ka) zuipFJ@4e>dHw<~~mSgbIrdkHPKm zPIw&r9oz-qh9ls1#3Li#4dwnGDD4&IBY$b2mkaX3_2C0Y;7y$W2V95f_MQ@s0Lwa- z>n}rT@F&oMuR|I6&<3NTv2Zl!2`DO90nw(~3gx{#6ro#@Jz_>g%AD{ z6v-Tc((}JSv58@)Ds?`b59Px>@DuPF*aoN4=*M6;l=t3-B2m?7Y-v0k#_3X@1Kv?9 zA@-rV;Ba^Y90_lRtKpsD{a2yb#GfFZq)up3>I_&9uZ6pzNa%Ig3V#jXgo%Xt;Piz? zLUZ7Ex$ZY}z&|w#AG#W@fTEjc;BD}mP)58RQAHrt%&rsT#wN$AO!gcUE_#7My&qZvvi2q;0!E0Rj4Llxx|4gOE!=FMNLA?pZ z3r;-CxX%K3E9c*Y5-+wbGaa}Yp1}D%5W7_mL!_>L1T8p}ho$55AeYnkn+}}=r*eKNWKe1^l#x9Qc~pH3N_+1>k;G}d zEXKMMio_l0Kg&Um0}*Xv6=4-Fg=zR3=)j$;jr)8JUdj2-A=*$ok@E-PO>j3H)uL28 z?1m@87hn?p5`Gu1IR~}D6Izw}G<={H`LE`nb&XOx;Pvo4_zSoeHt=U6%tJZ<5Py2$ z3lNuAYcP;QFbn6w6W5y&p9v?+btn@05R{Q0f-~X6a0Yx~y>E2)W_aNp_+f5L+hAGvSv5}Zh`f17nF`&4;R6E z;VAeL6czpgehL0R*bP6oO}FE#@!L%g=R$dL0VGDKU2qz_8QujSg3{6T7n+c|17e?Q zHmUBBl7J z&gKs>Mco1C!tcWb{2fF~Y9@-8_cL%Yd=RdNuR_uNbPPi7uYe0+FFcR$tIu#S8@>hc z3^nc&iA;C|ybb<46eHWe%lLgA7Szu9G$_V&6@TQtpF{ehW?hDU;XWwsz6Qs^y4`pL zJPDo(uZ6z6@Gu7=n&U1vH^#$>oPPkyi`zq{pp1Aw6l3~46j442r@%L$*u-CeheRib##9c{2~;cKNs?cP>k($I1`Re8c{bw zJWyQ*m%#_&7I_XXfoC{IMK0XO`4dpm@{&&9^kjLbxv&;a=Ei0yBkF>~;ME~-40$sY zqq_sng9qUd_*{7ZAE1ozMJN(^HRKymKKvFO3;)LtZw&7;H^xD^F%_N!7lh}lp}6A( z;dMK_-UBCa{X?(?-VMK8N63Yuy4zh7${&EDx(RzsC~t)#LEq-!WDc%|r@@0zMD_}t z3Ws$Y(anN$IL|@MMFrvY|Ayi%&1vHu9q?MtKMAG1=^0(;t2QW-y&F!4kHbZLU%kRX zfeS-1&5y%gNQ_XUbLNH1p~ZP0JPY0lN5fa(DELeG0r(r(1Q+B@hjzl*obQLy@q6HU z_)WMFj=DlRfc{r-a2^-ZP#Sy^iuHdB77*pD@IKBr7fm4f9hB$(49COcJX5SrgKu#D zAe52bTr!e;01o5)Rmd%M1d4I*=;1owS2+%(!5%mkUIRzMAUwYZKE?SX(1zW;MwkBx z7jym&ya6WmnvQ=3PT>4`cnmxO55a$h55N!inSi$hSMae5wTpve;kP08)bmiRcI|%C zkv1sSeo4qAln!U$Rj>~Zg>S=q;k&RG$=!3MF~W&FB=0vsQQZSzGUh(2JY2_xFT>&R9k>)8`w^3^RzMMP z7d!**h4S1ZP;~kvY=qCjjqtzV>u}XaO$X+D%t$B!#r;-7B;u3_eEyS^ zS@30u=c>aP6dtH%Vi+=jPs7i`Z^Pv<{b@5f{T%f7aN+d3_!vA0#o9-btan2Tz6B3J z5nUe3`FlA1AYz3-h8fs+ud((!p$!XXb`^C`SGwTm;=iN__|3c?kK7d$fPX z=>AqHqWLM50P$xiqMv-&=yV=j!1*R9*ZbfkcmrfG>K-@-E@!y=;0Aad{1%kwH{5R| zcp>~8=a=8_8~+{7SuYpnLmS=$Mbr{6=YWw=Mw);#;9c+ldR2=Ik?RZ&{^1Ko^#ArnQ@y?mhw)(Z!)8S5;aJXhz>zQo zPlQEyExaBsr=1ax7%ymj)Of{CD3a`gqLS;N4D2901^N$$54;4Ia^bg7dOVkI;-Bht z_&i(>Q*gqU%=N3FbnrH~41Nlp1z&*@5l5Gq6~R;CaqyS$c=+$I8U7JY5dS~x%O+26 zhHJTT1(XM$gfhZ!L+SBPpnTv6Tnwkt`VzPao(>N{8R=KxLijS24i9TnksiTVOlqe5(p)Vra&Vh)$05NEF0Ak3h;Tz_| zuRzgZ_cu+tEy7`(`*1G20sas^1jWh^F#Iv_E-1G25Uht!!5Q#ZP*ix#w-890gQedx zk?9az%lR*$h12F||$hr{*XHXXSDifvp9#k#wo4Cop-3?2-5KNPR|B9wtV z3FpACf7>@{^H*Gu0P$-$3jPtw3q!tRUN|0#geE{S$~jP;UmCIniYm^BqJqnzJm*1C z#SP*0Pr;Ko{~{DgebeVajOR6|<}IFEY_#Sr(SJ{yo3x7h((>?9d&#rBlw;W)9l280 zv)rPUFXTL@!*h~Wy`9XsS-0pFY%f=^_}7;!!QO22e1m0YlUA=?w6ZzR>PXqyE+^?H z!cRnv(sVkhpJ;cij)G&;xaq*a2hBaJUi#ZGu$iP|cX)1(ynWOs(vIi&18*~;PB+b` zDOd%^x06<9A(yc>xgCXEG1uu?r=78s z(U>QSJaV)ripwoKnPhx?-bn%M zzHhf9)!L2?XoJUk?6e!ohEWxq4#(|r__8kw$$E~lx}uZTM#uXy^cstdE8fi(JrQ57 z(@OFRwqQE@KB`M%u<4wQSZf~jEu&^6DaLDu9d$Eymm{`bV=YCyXTa;>+XW|+JIbxl zLd-|edDVFJ;U>p&v$Zy2*oMN1uGYftO`G99P_h{ zp@0T`Mm2}to^-VelY^eH>Wgkywu%0=r*j=By&-saOxuKH()HY2)=pc6T-qrbD-T|p zHNLJGoOi;Si&34M$>$25)m3teo_2_$Ro}9trGd#jcQbZYq$@)+Euh9QN))+jdzK?c zCwA?0<_hM?Vqdl+xaovTR)=;j=VJdDXPTarvRRBYVYQ_lyT}^_JVL}MgR-qor`IBg zl{}}I2>$hid44{hcIi5%I7qlf%UHPFv}2ly;#j>IoRe%6`w$7yQNE&eQgwDlAzksT zT(-k$NLZ~g)!V)iebMT$vtoAAitZ^E$Y_on2(&fUHrYsb@^-PJ=&(f{ z)m!qm1Y{e@h26^$;N48dNxDS4bYEhdwIk&i$Im;3B5xx}ZI4GY$40F#E%d4pi?%90 znkbJL`^b>dMK>#v39rgJ<*$r;w{D2*l^;2&scvOtOj)OgIAtvjQ<#3e6 zggPge$=lh!V9dlfk0mt~`hvG7-r~~*REazWY0tu7EcrlROmzfa6P8g^u_Kpv8nuq` zo&mwFD3P?*r5den$H^0}wXKU>^9A2zl1|r2CyBs$F%`)pTI|(0rN?C4FE&nX;JpsY zsW={84kO4QA52)AvE7PybU6~EmK5j*sU?z={w_h*^n8VtcXD}Ra%5FvM#0rhll*f^ zMUU6gxh@2e#|Kp_*`!ZZZ8LVLbvsDDu?LdzV?KtRTT*C*$&kC9b}8*?$?207oGp*3 zg?{9?A@TdDAIsYXFGF?+9-TDB595?5A&P2MhZ7}7a#F2sF`u^ktgM}3kZzBg#N<3@ zucumb;#w(!1&1ajSRGEmb34V69IHOTS%YfDfCwrPwM&iKTXBn#WKR9Jd^H8duWZ+CWZ~R)R<5Esit#u1TCJ|2gktuC; zt?MLEo7Q;IDM)Y+jmXXRP<=>*!^m~z$9L?km$1(7>=buN8Z{?^zfAr8>{0=9TA#yW z=~#yF#G9e#=#ZOSRsPMii9@vN4dFF0v!XaP@+FR1 zDYCOw6yJ|hQ^RdD(w)^(L4sAy+Y%||Vy6~I#`ZeIF08IX4xh$F6DL4dSv6BtdE(MC4V9$)^5$ERGEzkARPVo5|>sly-OOog6i$?i`2dSj3bacb* z-XUt0L=c&uHq+p-xlP+lqLGMOw0fO%I-xe|Di9#^ ze8K5)onEyu*M;vVE!?%x$ADs|BwKUA!zjhil8N%3nK#$@G6*}A_|f}sN!$}{h%Fi8 zMh-4UxItbIa~S!yN0K_v>0z3jrC$-pGc{UR7KCN*NbM*h>qPK->&@T?vo?%y3WZ#O znMpAiKKoEGe)ft9MQiqUOf|8EnlsUyPS2Kr8@x2{)Zn(+|1z>&W)(W$1^GEEhvAN- zg!1R-Tt8$~=!LqmruiRi)Gi?=CYGnORC_MjC!;gnj$3DN@BCBeNHt)*q*hDfUZI+T zN9Qm1H|L1zOuMKGLiLB%VWN!Dy4ozIhDSujv(9hdL#a*3Qur|wi$R%Chn8%*FDi#5 z>V|4?3o<{{vni7UeMYP&QY%FI+%`VXwPEcDXvK;?2EWk*P6P`Vy)!(Tnw0AoKQd(a7UznROJ9P=7ED=MUk%T+ zpHESW>QF`xi+Fp(a;wHaxQ(#{ZL?4EjSh^cwemy=2^F=j%eC322AExqQYNa?OjIX} zF;)vJ5s;n$>UkoCPizXQi_0{+;v>2Ag-%gzDVazvTCF*;>=NY=)pG}K)>nHgOAgcJ zw-|+<_mNsWnFtqGS8CbsaeP)z=o8xIi&T(IWWu?g zEYiGJW9Y;>N6bXGBa#Z;qv9AQJ?M~{)dTUhWaLRR(Ww>IG2 z@`>F^hcMFP7MZ^6Hhm|ikWZQW)<}g!Y3pwqftUgKvIZox1|#E`D3&a;=0)KzoXb^2 zT3?x$Ep2FwDnUtl?=6+e@I`exiO9rOyYViqGFd0FnypUiXs!-$6C%+SdJ1gIv{y82 z!n`yVPHman=wft07lI$1_8)$=HC@|!UcqIhPO>oGz%<6yk%CAjlCM_k$W(`BQk`as9M5L5 z)F*Rta*U)o?Lo7(+Gp#W7p@jjHu9B>3Jj(SpL z>gZ&HFQ5L~DQ0z|c0wtADF%;!z*(Z33RfDXQpFtg#5t;Ovn`*oWhISkwJ~o z%HU$teXV*DAX!&!V~|kJhHB2HPLNT^S_QGT z)9orrn(A}BM6iF^nJeOTiIoiNdD8W%A{4YW6hF|^tZ_LOLR#t#6eeRf$DkKAgPMFt=YYKtC~(NU%E8g zs^8Jpbk4>NgddTZcqz{sm#s|!2@9)WflJ$WC895X`Vn>!KQ&M^HRV!DFJB ziH?oBjfIk!^#$>oC?=S0XlaSoM5NLuq#o>D@f<6s6~m<@cK9llu!|BAm8s=XTK&51 z8(JG87dj`VW4m3qk1&|N5T$ffG1F1Kpe)j?T7e9s`Z=++X#?k-JYSi1E?b$JiM%Gg-@S4~-N;@lH);`9A%eqa|K8W-s@g6~=rZo*R#Y2QMyNZ{o2slK8}*QX-@71nzD$u_8J9%x6au~SO&;IY*Q)|h!R!;r=7XdxlC zB0-byW%4?h$~(GlDKBXLSe+lnRu$7K$9g?JqBv`f6{*Uq*PuIQ`AX^eS5cYFt<0(i zjzBckVXH~-agN& zubG!wOn0QLLP3t(<*=$?UTv^S_yX7tG*(L3F*=_}o+zFYZJ(rR(oSlEZe?9#x%F9Tkx)eUDnA+eZ_gUS{^nTsUBUEx_#5O-_*+d2s2=^g?K}CmSoedU~QBw8p|o9?NU26 zyJa3_o4NmKS)Ixg#;_gJ-F8`+B4{QfQ?kg--P!)9^PS;9mKr7ODzCVAwID>c5LFW$hHju5^7vHf1x<5QTL)z5Rz%X?8v7 z8&})O$5g$>U3~GExcw0-Jh6JQI1J7Vu4*&2wnpv6j)g-}m$tF{(A);SGiU%ss|EWL zC-e)oE#k3x2gj=#@1WM0jdwEC!Ci}6uX&3YRCTO@!5A?($(6`TKND_7wh}}t`6V;h?2HZZwR$U2n8!DDOZHmqa5Y-uyE)l1YR z(T;dlCiv6-X;fD(0X*Em)FDtudTJDmpQd%t$mq3y`CRn71qsh0aK|(In32^uksP}F zAE)djOh|>)MORIv$T%tNN9&54M-KP_J|O-ly{a}ZW7n73%(fp(RPs=BRzd+vE{5A& zo-RRj;L?eN-v}^ml4KTjkL6&ZfvBPl8KR3?Gg_qQy}Ae5svD*FiUy+tgVtFkRVK@5>l8E4L7~JpfF4ua zGqvKv;Ar_~?8Y~0HGv`qy%t3-Q((xll~bz7&nf7LZM@;)QYQ;`RkM)pnfzf!u|kii z*{?(ir_h;8Bg$~NHIi&8c^(zHTB9o+i%knR+q9Jo>Zon~hr2}8>`_K$;$*88br=Av z1L0=%Nl!JzPMCOL^YPtLCXiWKV4*Ti2V>i&&8c-@id~&!XtSC0>ZZ}I3U2P0WG~#N zCwFX7R(eIy5E)eRQf%U;EyXB8HD~wfdQSF4w-Mm7RHd$yZQ3ULqMI2gRRdNOT+%kH zaT~TmX75zdfp$La*UW!(30Nuqg8gmDF~;NUbhgCr3<~Af+J@Kp=W1W8gjBj&u~`(V zEey9|2CD6h-$>vvp~=NwDn!;AgWtsb83`xv3z_a+kdzt zC1K9m$iTufr6a{kOS==ie!I8bzzPoBI5+V z-ZH&>@0O3$UA(UUFb$fpYBo-z&=Un<>|P3KK5En^(YR6&h!`>S$P_aDhx-q@%>PTp zM7d$xn7YMY|I^u|Ho+Z;#?-46h+VS#n3ZkwP+CZ)i&11(m6vYM4Dl;cuE~A!`?GM< zNawe3msRqBiX~3kN4PQTWCv4nP^HmsezU?RQ>s9cj5?6A`8vCy{J6l)1`q7`l;12X z^r985tD5FSX%a4p(F{SNyHtkkT#%N{^QN|>He)%1vVM!>Qap+ev#E?9$tJ8(vwTx( zES50RYK^p)CT!8&VADk_hqGm)f2tJRc#*epYrbGhR+a5oraaPt7Mp#MY_ci5)yOg~ zafPf=lucO?OoQ6YOJ-MAUJLCm`13{k&ukIPDA12?G03d7!E9=aZEbQrB8RqqZQzA) za3qnQ;J=geFC&qjpQ7M3W0oHeQFKa>k5#WyEE}VVwq&J8#!Chk-(mO;Yw+N;-BbK7 zDGkIZ^q>;z!b-)hcWs+6X8LcJWE;xMbrXh|KP9a-Ea(>OF1!*CtB`4GcJ9n)+k(!WgttN>8j@kRY zW7o66eY<9jSVMZ262zNUR8@oB=A4z!q`_(x1OH69sE zZ9i*)ex@ZVT;tXf28wD74VJW@@1KVhqmj!GXiNw-)iC1{k2YC=M1lh_ZcpzLcY946 zT2*t2X(uKh&f}t(9DXWG%peGbQ#zfETmnSAYG5)$7Ol*nS6KRiFo5wBQWpt{MkSn@ zkUC(yI0~unUuHLkMG0fAS-h~OM_r|Lx*C>Z3DtrL=gJ4#KRsmGW=0Wh9@7;vq!v@m zu_J$_l&bhX6S&~S&WYuP&d-KS3cp?rmrY`Y%Y&D8O)AeyU3lziEL1X9Z=an?s}>IE z2vMz?x+~dE7b{tQwdVz;OjEeEZ0fIY=UA30_yr6RtGQ-JS+1kq$O``Ey=2s0nO;gg zL3}3WVH!lYW_&K%Sl)^R!^R!2*B0-TNrM-bOR{xR%@$qk@%2B=G{1abW^CQ)EtR4L ze|R*z{+JP1aR1{>+`ZrjyC$E+*J3+WX0tiCJT-aQ;5}x%T|z`SOJLrWt%TEk)p?-V zhNixw29%%dNH#S!HIBS7tg-OnFd$d_DI8!}zvB;{hl`P^kVaGu%Bq1a2}vsbXi)*% zO0z4=f`qJi=w0ZvPw}9m5eb=k9?z(pn;mGZNz#7Jy*)ZlN%N9jV#?ucjb5ZqOI65O z_03fS{4!iw^XX~a`Lva?LzMM&86O*wnR`{tQEyzcFTGZGn$kdWwhqTq(3lcY*DGo( zawTA4(FE*VKHAGR^5bU!c$p02=$~RUThA9UXPJR*Wa-?~N;R9=*p$S}gvFYVsXz6q zkF;OAMx*B-u>rJUetsu)1v(NPR05uuxO6Ej%;eOBJ$+THi&PZNx2^hWufnXPr@KZ! z8EdWHxz?LXgSWW-_#Ssuxv6T3hMz-MiSnYpYa#izieD#^ck16%4~ z9*#ms(F$->plMg92#3+LQ^`7vzQhW9vbeO725o%@-1rs`PI#$ZW zVqE!c3QuJv{4;6_bXP}aJu?wE4eiM$TuH%pzdBdP(jxAkVyjKB{}Y0@Is9@LMK!H0 zQN+{X@{6U}b?4K72`eVf#Jxn6pc6C7k1ss&L+aX>Vk=AK-}c;GcQ##ZXPr$7JXZLt zt6yuX+v{c-FkC#tuUN}wY?NV%VDH|QqEqBqSu~(N)W6wdPGDY%)(>^Qjm0|@tpzE$ zqWtLI)kBU8y)l@zg+(H*UU37uh4yqYk`&1?)!4|wwF#Ws%5+UNU^SApNJ_4Zj+rf{ z!_rbwk{{df#4|Z&w@It4R%6(|Pl17k?y2367e$GWR5#>XF?c;~C;mys=cg7D(BCI3 zA`Xm@bxG}o5~ebQdXuu2TWk2e1xpW}A5(?I15q&hLkOL;MtqXEq~m-TwZlZl|N9}+ zH)CfEH@wU2f`01MA7l zZpL2;xAfg0(LIr$($O9(C_B)X**}p5X^F706PYat{iPZD zGqpAzbByxYSFWxbdvPx<#VWz_l^>j0=j(8xYCgYQR30n7toVSdkyJKi%mz-&-&QG$ z3z(fRCL7`Aj?eCn>=H>quS<|HX&$AdOQ_8RWv{yLFY!cT4s#z_m6xQz)?BzVr#~Xe zRelRn;HO{|O0u(>#euAg#c8rT+0du_h;RLybx1KustfV(tR=rO;0FL=bjBl1DR^|( z;LAai|L`#Lla=6S{yWQ@w5)GLf`0E`1vIr_hp{zGJ^u<`KX9d6A{R0Yi4v7*06S;b z;cMYmp4H08wQ>K=PDuX)D{9U$^<(*xs~@cM!^+fCF)LD)vP}_csxeX=^L=i`TQCbD z%1q=H{S2q1`ga5@4k=wVZgPFIK%DQ)2hcDP6~sV+K|w=aLih$0pNJ;nD>WYtaD$O)24{vbOdL_NP%`n9 zWM(NzwI9pEYu3i5xmK+0mbE=>sWd*NuBB;hTWSyYce+!{2j+YJ_s-mV&j0^EcWB!h z^yx;6|5V@LhXp@f{0-zUvAc@?|C8EF2sdFG7T|7-#Pi6n_?4F+3=0=xEcU^8oQVnO zz)akNL+~~1f#8RuE zKzs;qBz_h7s5l;oJCRF>v$&o6iz^J~a^S%TAqL~TSPrZV>7z34e!Uq zeykp!Lv@{YT!P_ILWH6N)rx(nl3Rzt*nnZ&Uuv$Y5GMfy1i3X~}bEsBmj}>AYI?;|AcO8$Fn27u0$Uln}?{eS< zJcmhm8K+?soupR03{?s%F&}-X-uE)r;wh}hC04W6U3iN4JEV%@J(fQh&){|p9b`7^ z?m>RDi$CE2eJH*{)uq=k9TWJJ6=&lZT#55FW{xT|8nd_?N8=i7!#qsqA|zAXg(OMb zhpGu%P<_t8L->`SfrG)Gp=S5?y4l=@UQ`!sLbbwP$wDm0Ml8V#sCuM`f|j8TkK>!T z5x1rYu@n2J(idppZTLOTz?o@6Ohx}A45ApE4jlLaeZ;vepL>gg*n*#-O8Ghptyb_9 zsvh|a)e3(=swVmjH+REutRl|C8hjqrc1+F`;$9qqJ=OkiXRwF^f5XK%D9hZ>wWuyw zk4y1MR5#RaF}Gz5&L!S}Tu7Y6arh;6LuI73f1-u8^t8K zzv#zc5{^W5q1C9mePbYQLdqs~VG6#5lwbT1h^?cASV%k*nWJb$wY@r#s)_4(E2h)I zBw4IMmD&C1S1Eg&fx2+$ShG1sAR9o`;bh#4Tk$-q=3C8&R7pO=QXH3KK4_jmC-DKK z4@E+5U}C7^R#X{!7yIJ5T=K65*EpbN8phrU!yHsAF2rbDg`2SncVX;!^Fh>(Dq}}c z-QaE9hgVS5c<%(WWCq3)e~KytKO<`v>G^&k<}j$pH*3_6DplX$YRs7^gauzhe#I-i z9Ay9hf)5cNAU&!Hr%f@Nb0?~1JBYtc44Jcd8xyc6du|Y>BO68(`x&SvszddG1E^AT z828|ZsCr=)k3)5SJx1Ubd;nYU3=S_aClEE=Tu26X<9G>r(H@9Tp}Ni)RL$-GmVvr~ zrO-SOhV8_0Xv0oarOBCLrmhk<67NDa(ZreNm(w_GAuh&e@Ow;F?<1Wnumfu_YnHj2 zp2Z$&{~uzY_T|xlZ=ssu`?wZQ<8EA5WH!xpyotE)Y_p~*s8%u~5HGuoC6~<7RymRQt!od{0dddqbam%#x%^wX}AL$P^EhCJSMKu zh(37|)}D-{9liU$oq2e%s|QLpb;Z+^zAet6TB5wym$%uAIiYR-~cwGT3O*z^Tv(X zi+C4iq3&{vWUcA0xhiYT^wx^K1AcOd!5yBmeyw z45VP{4cYh})}h*db9uVlgYTfaVIg&C#cFKC0lL{-&jgIP)2!`UT+aE2@DhHH5962R zLi`z5SMW8C*>>`O7lTc9v*uS&omjfUe8wBdqQpD61Jf$aUg^LN;wz|nVvfmREnO*zIY68!jEtiUP5)Dp=-?*=i)$OAMQkn&6vT(x?>04h$k=+Kfw=BHHfPH zhp3(tUt&QVgRnX?g{e4}cp0kH@4y`V9G7G4dh^*|i^0UfG>XbZD9ZW)ozfUMO(w-o zl5fN=l*{7=8z03#XEC-7x~3UTiNmxkr^{+9Ep=5py&kL0ty@doy3MPXrCK>rW%GDe zx!h%eGZi*xxn7noe@wb4FARAgyxQY+-DP#S%I!|xS}w;V4@j`P^-{0PU1Po3t2=E@ zZ?bjzWHH(9vzO^^t5?6KCm@@h(+%u6jW9!)LMWP18#V^T(hMIOmq zA)B(s%g(H+vg{UD51($Wa_c_3zDoKVqm2vMK282-deW0kYJCO>w~m5ZvT z$QxJM;17taLsycg6^@d(%ogc(^{$9ow{{~%SlD7s)P=` z+-t3NmIcntR#iTB$n9nJ<@&L`PDlCYkWC)>#-`O_GwnV-ZGp?}DXp+o8~(<(G~<^oF`7);I!4xPJt+Ux^k4bOwkgIi z`I9AR>aoAbKDCjuXnTW^(8kXy4gsG4?&~43V?;rWj*e!!={U zQ`3TsbI)aHviOA`WxoS+xudsx2H>$a79yt>1oyNz29W^2a#FE(m& z&r72W>&rP7dA|L$tbMi02s@mo8B31DX-U7Qlx6=>VCMcuR@1Fh>`poU@u+TO+3qZt po7#%ydq?-njeq?q?Dx8-O?23*