Microsoft 365 user sync: import, roles, profile fields, deprovisioning #1

Open
friloo wants to merge 11 commits from feature/user-sync into claude/modest-hypatia-2gfg7m
20 changed files with 5532 additions and 982 deletions
Showing only changes of commit 4edf20bc45 - Show all commits

View file

@ -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 [Keep a Changelog](https://keepachangelog.com/) and the plugin adheres to
[Semantic Versioning](https://semver.org/). [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 ## [1.0.0] 2026-09-22
### Added ### Added

134
README.md
View file

@ -31,6 +31,7 @@
- [Gruppen-Beschränkung](#gruppen-beschränkung) - [Gruppen-Beschränkung](#gruppen-beschränkung)
- [Nur-Button-Modus & Fallback](#nur-button-modus--fallback) - [Nur-Button-Modus & Fallback](#nur-button-modus--fallback)
- [Eigene Login-Seite](#eigene-login-seite) - [Eigene Login-Seite](#eigene-login-seite)
- [Benutzer-Sync](#benutzer-sync)
- [Sicherheitskonzept](#sicherheitskonzept) - [Sicherheitskonzept](#sicherheitskonzept)
- [Shortcode & Hooks](#shortcode--hooks) - [Shortcode & Hooks](#shortcode--hooks)
- [Fehlerbehebung](#fehlerbehebung) - [Fehlerbehebung](#fehlerbehebung)
@ -46,7 +47,8 @@
| | | | | |
| --- | --- | | --- | --- |
| 🔑 **Login per Microsoft** | Ein Klick auf der Anmeldeseite, Anmeldung bei Microsoft, zurück in WordPress fertig. | | 🔑 **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. | | 🎨 **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. | | 👥 **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. | | 🚪 **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
</details> </details>
Wichtig: Jeder Benutzer, der sich per Microsoft anmelden soll, braucht in WordPress **dieselbe E-Mail-Adresse** wie in Microsoft 365. <details>
<summary><strong>Zusätzlich für den Benutzer-Sync</strong></summary>
| 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`).
</details>
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. 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 ## 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. | | 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`. | | 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. | | 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-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). | | 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`). | | 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' ); 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 ## 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. | | *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. | | *… 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. | | *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. 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-crypto.php AES-256-GCM für das Client Secret
class-m365-login-jwt.php RS256-Verifikation, JWKS → PEM class-m365-login-jwt.php RS256-Verifikation, JWKS → PEM
class-m365-login-auth.php OAuth-Flow, Callback, Benutzerzuordnung, Nur-Button-Modus 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-button.php Ausgabe auf wp-login.php, Shortcode
class-m365-login-admin.php Einstellungsseite, AJAX class-m365-login-admin.php Einstellungsseite, AJAX
assets/ CSS/JS für Login-Seite und Backend (unminifiziert) 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
<details> <details>
<summary><strong>Kann ich Benutzer automatisch anlegen lassen?</strong></summary> <summary><strong>Kann ich Benutzer automatisch anlegen lassen?</strong></summary>
Nein, bewusst nicht. Der Admin entscheidet, wer ein Konto hat. Wer Auto-Provisioning braucht, kann es über den Hook <code>m365_login_allow_user</code> nicht nachrüsten das wäre ein anderes Sicherheitsmodell. Ja, mit dem <a href="#benutzer-sync">Benutzer-Sync</a>: 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.
</details> </details>
<details> <details>
@ -427,7 +544,12 @@ Ja. Einstellungen gelten pro Site; der Benutzer muss Mitglied der Site (oder Sup
<details> <details>
<summary><strong>Was passiert beim Deinstallieren?</strong></summary> <summary><strong>Was passiert beim Deinstallieren?</strong></summary>
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 (<code>m365_*</code>) bleiben erhalten. Deaktivierte Konten sind danach wieder aktiv; wer sie sperren will, sollte sie vorher löschen.
</details>
<details>
<summary><strong>Benutzer-Sync und Multisite?</strong></summary>
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.
</details> </details>
<details> <details>

View file

@ -945,3 +945,177 @@
color: #8a2424; color: #8a2424;
border: 1px solid #f0b8bd; 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;
}

View file

@ -221,73 +221,69 @@
} }
} ); } );
/* ---------------- Entra group picker ---------------- */ /* ---------------- Entra group pickers ---------------- */
var $groupList = $( '#m365-group-list' );
var $groupResults = $( '#m365-group-results' );
var optionName = ( $( '#m365-tenant' ).attr( 'name' ) || '' ).replace( /\[tenant_id\]$/, '' ); 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 ) { function escapeHtml( str ) {
return $( '<div>' ).text( str || '' ).html(); return $( '<div>' ).text( str || '' ).html();
} }
function refreshGroupList() { $( '.m365-picker' ).each( function () {
$groupList.toggleClass( 'is-empty', 0 === $groupList.children( 'li' ).length ); 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 refresh() {
$list.toggleClass( 'is-empty', 0 === $list.children( 'li' ).length );
} }
function addGroup( id, name ) { function has( id ) {
id = ( id || '' ).toLowerCase(); return $list.find( 'li[data-id="' + id + '"]' ).length > 0;
if ( ! /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test( id ) ) {
return;
} }
if ( $groupList.find( 'li[data-id="' + id + '"]' ).length ) {
function add( id, name ) {
id = ( id || '' ).toLowerCase();
if ( ! guidPattern.test( id ) || has( id ) ) {
return; return;
} }
var $li = $( '<li class="m365-group-chip">' ).attr( 'data-id', id ); var $li = $( '<li class="m365-group-chip">' ).attr( 'data-id', id );
if ( 'roles' === mode ) {
$li.addClass( 'm365-group-chip--role' );
$li.append( $( '<button type="button" class="m365-group-chip__up">&uarr;</button>' ).attr( 'aria-label', i18n.moveUp || '' ) );
}
$li.append( $( '<span class="m365-group-chip__name">' ).text( name || id ) ); $li.append( $( '<span class="m365-group-chip__name">' ).text( name || id ) );
$li.append( $( '<code class="m365-group-chip__id">' ).text( id ) ); $li.append( $( '<code class="m365-group-chip__id">' ).text( id ) );
$li.append( $( '<input type="hidden">' ).attr( 'name', optionName + '[allowed_groups][' + id + ']' ).val( name || id ) ); if ( 'roles' === mode ) {
$li.append( $( '<button type="button" class="m365-group-chip__remove" aria-label="' + escapeHtml( i18n.remove ) + '">&times;</button>' ) ); $li.append( '<span class="m365-group-chip__arrow" aria-hidden="true">&rarr;</span>' );
$groupList.append( $li ); var $select = $picker.find( '.m365-picker__role-template' ).clone();
refreshGroupList(); $select.removeClass( 'm365-picker__role-template' ).prop( 'hidden', false ).prop( 'disabled', false ).attr( 'name', base + '[' + id + '][role]' );
$li.append( $select );
$li.append( $( '<input type="hidden">' ).attr( 'name', base + '[' + id + '][name]' ).val( name || id ) );
} else {
$li.append( $( '<input type="hidden">' ).attr( 'name', base + '[' + id + ']' ).val( name || id ) );
}
$li.append( $( '<button type="button" class="m365-group-chip__remove">&times;</button>' ).attr( 'aria-label', i18n.remove || '' ) );
$list.append( $li );
refresh();
markDirty();
} }
$groupList.on( 'click', '.m365-group-chip__remove', function () { $list.on( 'click', '.m365-group-chip__remove', function () {
$( this ).closest( 'li' ).remove(); $( this ).closest( 'li' ).remove();
refreshGroupList(); refresh();
markDirty();
} ); } );
refreshGroupList(); $list.on( 'click', '.m365-group-chip__up', function () {
var $li = $( this ).closest( 'li' );
function searchGroups() { $li.prev().before( $li );
var query = $.trim( $( '#m365-group-search' ).val() ); markDirty();
$groupResults.prop( 'hidden', false ).removeClass( 'is-error' ).html( '<p class="m365-group-results__status">' + escapeHtml( i18n.searching ) + '</p>' );
$.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( '<p class="m365-group-results__status">' + escapeHtml( msg ) + '</p>' );
// 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: '' } ) );
}
return;
}
var groups = res.data.groups || [];
if ( ! groups.length ) {
$groupResults.html( '<p class="m365-group-results__status">' + escapeHtml( i18n.noGroups ) + '</p>' );
return;
}
$groupResults.empty();
$.each( groups, function ( i, g ) {
$groupResults.append( buildResult( g ) );
} ); } );
} ).fail( function () { refresh();
$groupResults.addClass( 'is-error' ).html( '<p class="m365-group-results__status">' + escapeHtml( i18n.testFailed ) + '</p>' );
} );
}
function buildResult( g ) { function buildResult( g ) {
var $row = $( '<div class="m365-group-result">' ); var $row = $( '<div class="m365-group-result">' );
@ -301,22 +297,90 @@
$meta.append( $( '<em>' ).text( g.description ) ); $meta.append( $( '<em>' ).text( g.description ) );
} }
var $btn = $( '<button type="button" class="button button-small">' ).text( i18n.add ).on( 'click', function () { var $btn = $( '<button type="button" class="button button-small">' ).text( i18n.add ).on( 'click', function () {
addGroup( g.id, g.name ); add( g.id, g.name );
$( this ).prop( 'disabled', true ); $( this ).prop( 'disabled', true );
} ); } );
if ( $groupList.find( 'li[data-id="' + ( g.id || '' ).toLowerCase() + '"]' ).length ) { if ( has( ( g.id || '' ).toLowerCase() ) ) {
$btn.prop( 'disabled', true ); $btn.prop( 'disabled', true );
} }
return $row.append( $meta ).append( $btn ); return $row.append( $meta ).append( $btn );
} }
$( '#m365-group-search-btn' ).on( 'click', searchGroups ); function search() {
$( '#m365-group-search' ).on( 'keydown', function ( e ) { var query = $.trim( $query.val() );
$results.prop( 'hidden', false ).removeClass( 'is-error' ).html( '<p class="m365-group-results__status">' + escapeHtml( i18n.searching ) + '</p>' );
$.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;
$results.addClass( 'is-error' ).html( '<p class="m365-group-results__status">' + escapeHtml( msg ) + '</p>' );
// Allow adding a pasted GUID even when Graph is unavailable.
if ( /^[0-9a-f-]{36}$/i.test( query ) ) {
$results.append( buildResult( { id: query, name: query, type: '', description: '' } ) );
}
return;
}
var groups = res.data.groups || [];
if ( ! groups.length ) {
$results.html( '<p class="m365-group-results__status">' + escapeHtml( i18n.noGroups ) + '</p>' );
return;
}
$results.empty();
$.each( groups, function ( i, g ) {
$results.append( buildResult( g ) );
} );
} ).fail( function () {
$results.addClass( 'is-error' ).html( '<p class="m365-group-results__status">' + escapeHtml( i18n.testFailed ) + '</p>' );
} );
}
$picker.find( '.m365-picker__search' ).on( 'click', search );
$query.on( 'keydown', function ( e ) {
if ( 'Enter' === e.key ) { if ( 'Enter' === e.key ) {
e.preventDefault(); e.preventDefault();
searchGroups(); search();
} }
} ); } );
} );
/* ---------------- User sync ---------------- */
var dirty = false;
function markDirty() {
dirty = true;
}
$( '.m365-admin__form' ).on( 'change input', ':input:not(.m365-picker__query)', markDirty );
$( '.m365-sync-run' ).on( 'click', function () {
var $btn = $( this );
var op = $btn.data( 'op' );
var $report = $( '#m365-sync-report' );
if ( dirty && ! window.confirm( i18n.unsaved ) ) {
return;
}
if ( 'live' === op && ! window.confirm( i18n.confirmSync ) ) {
return;
}
$( '.m365-sync-run' ).prop( 'disabled', true );
$report.html( $( '<p class="m365-inline-result">' ).text( i18n.syncRunning ) );
$.post( cfg.ajaxUrl, { action: cfg.syncAction, nonce: cfg.nonce, op: op } ).done( function ( res ) {
if ( res && res.success ) {
$report.html( res.data.html );
} else {
$report.html( $( '<p class="m365-inline-result is-error">' ).text( ( res && res.data && res.data.message ) || i18n.syncFailed ) );
}
} ).fail( function () {
$report.html( $( '<p class="m365-inline-result is-error">' ).text( i18n.syncFailed ) );
} ).always( function () {
$( '.m365-sync-run' ).prop( 'disabled', false );
} );
} );
/* ---------------- Fallback key ---------------- */ /* ---------------- Fallback key ---------------- */
$( '#m365-fallback-regenerate' ).on( 'change', function () { $( '#m365-fallback-regenerate' ).on( 'change', function () {

View file

@ -62,12 +62,12 @@ header = '''# Copyright (C) 2026 friloo
# This file is distributed under the GPL-2.0-or-later. # This file is distributed under the GPL-2.0-or-later.
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: M365 Login 1.0.0\\n" "Project-Id-Version: M365 Login 1.1.0\\n"
"Report-Msgid-Bugs-To: https://github.com/friloo/wp-m365-login/issues\\n" "Report-Msgid-Bugs-To: https://github.com/friloo/wp-m365-login/issues\\n"
"MIME-Version: 1.0\\n" "MIME-Version: 1.0\\n"
"Content-Type: text/plain; charset=UTF-8\\n" "Content-Type: text/plain; charset=UTF-8\\n"
"Content-Transfer-Encoding: 8bit\\n" "Content-Transfer-Encoding: 8bit\\n"
"POT-Creation-Date: 2026-09-22T00:00:00+00:00\\n" "POT-Creation-Date: 2026-09-23T00:00:00+00:00\\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n"
"Language-Team: LANGUAGE <LL@li.org>\\n" "Language-Team: LANGUAGE <LL@li.org>\\n"

View file

@ -1,6 +1,6 @@
# Security-Audit: M365 Login 1.0.0 # Security-Audit: M365 Login 1.1.0
**Stand:** 22.09.2026 · **Umfang:** gesamter Plugin-Code (PHP, JS, CSS), Konfiguration, Deployment-Hinweise · **Stand:** 22.09.2026, Nachtrag Benutzer-Sync 23.09.2026 · **Umfang:** gesamter Plugin-Code (PHP, JS, CSS), Konfiguration, Deployment-Hinweise ·
**Methode:** manuelle Code-Review gegen OAuth 2.0 / OpenID Connect Best Current Practice (RFC 6749, RFC 7636 PKCE, **Methode:** manuelle Code-Review gegen OAuth 2.0 / OpenID Connect Best Current Practice (RFC 6749, RFC 7636 PKCE,
RFC 7523 Client Assertions, OAuth 2.0 Security BCP), OWASP ASVS 4.0 (V2 Authentication, V3 Session, V5 Validation, RFC 7523 Client Assertions, OAuth 2.0 Security BCP), OWASP ASVS 4.0 (V2 Authentication, V3 Session, V5 Validation,
V6 Cryptography), WordPress Plugin Handbook „Security“ sowie isolierte Tests der sicherheitskritischen Klassen. V6 Cryptography), WordPress Plugin Handbook „Security“ sowie isolierte Tests der sicherheitskritischen Klassen.
@ -8,6 +8,8 @@ V6 Cryptography), WordPress Plugin Handbook „Security“ sowie isolierte Tests
> Der Audit wurde ohne laufende WordPress-Instanz durchgeführt. Alle Aussagen zum Laufzeitverhalten beruhen auf > Der Audit wurde ohne laufende WordPress-Instanz durchgeführt. Alle Aussagen zum Laufzeitverhalten beruhen auf
> Code-Lesung und den isolierten Tests (JWT-Verifikation, Verschlüsselung, Zertifikate, Eingabeverarbeitung, > Code-Lesung und den isolierten Tests (JWT-Verifikation, Verschlüsselung, Zertifikate, Eingabeverarbeitung,
> Nur-Button-Sperre). Ein Penetrationstest gegen eine echte Installation steht aus und wird empfohlen. > Nur-Button-Sperre). Ein Penetrationstest gegen eine echte Installation steht aus und wird empfohlen.
> Der Benutzer-Sync (N-7) wurde zusätzlich in einer echten WordPress-Installation (7.1, SQLite) gegen eine simulierte
> Graph-API getestet (Import, Paging, Rollen, Profilfelder, Fotos, Deaktivierung, Löschen, Sicherheitsstopp, Graph-Fehler).
## 1. Zusammenfassung ## 1. Zusammenfassung
@ -112,6 +114,32 @@ Alle Requests laufen über die WordPress-HTTP-API mit Zertifikatsprüfung. Setzt
fälschen. Das Plugin erzwingt `sslverify => true` für seine eigenen Requests nicht explizit, weil WordPress-Konventionen fälschen. Das Plugin erzwingt `sslverify => true` für seine eigenen Requests nicht explizit, weil WordPress-Konventionen
den Site-Betreiber entscheiden lassen. Empfehlung: `https_ssl_verify` nie global deaktivieren. den Site-Betreiber entscheiden lassen. Empfehlung: `https_ssl_verify` nie global deaktivieren.
### N-7 · Benutzer-Sync (1.1.0) — **neu, geprüft**
Der Sync legt Konten an, ändert Rollen und deaktiviert bzw. löscht Konten. Geprüft und abgesichert:
- **Auslösung:** nur durch Administratoren (`manage_options` + `create_users`, AJAX-Nonce), per WP-Cron nach expliziter
Aktivierung oder per WP-CLI. Der Login selbst legt weiterhin nie Konten an.
- **Vertrauensgrenze:** Wie beim Login gilt der gepinnte Tenant als vertrauenswürdig (Sync verlangt eine Tenant-GUID).
Bestehende Konten werden über die E-Mail-Adresse verknüpft; ein Konto mit abweichender gespeicherter Objekt-ID wird
übersprungen, eine E-Mail-Änderung auf eine bereits vergebene Adresse abgelehnt.
- **Fehlkonfiguration / Teilausfälle:** Jede fehlgeschlagene Graph-Anfrage bricht den Lauf vor jeder Deaktivierung ab.
„Gelöscht“ nur bei HTTP 404 für die konkrete Objekt-ID. Mehr als 20 % (mind. 5) Deaktivierungen/Löschungen pro Lauf →
Sicherheitsstopp ohne Änderungen. Testlauf ohne Schreibzugriffe. Sperre gegen Parallelläufe.
- **Rechteausweitung/-entzug:** Rollen werden nur bei importierten Konten (oder auf ausdrücklichen Wunsch) verwaltet.
Bestehende Administratoren, Super-Admins und das eigene Konto werden nie umgestuft, deaktiviert oder gelöscht.
Rollen-Slugs werden beim Speichern gegen existierende Rollen geprüft.
- **Deaktivierung:** blockiert Passwort- und Anwendungspasswort-Logins (`authenticate`, Priorität 100), bestehende Sessions
(`determine_current_user`, alle Session-Tokens werden gelöscht) und den Microsoft-Login. Löschen nur mit Übernahme der
Inhalte durch einen gültigen anderen Benutzer, sonst Deaktivierung.
- **Profilbilder:** Größenlimit 2 MB, Typprüfung per `getimagesizefromstring` (nur JPEG/PNG/GIF), Ablage über `wp_upload_bits`
in einem eigenen Unterordner, Dateiname aus gesalzenem Hash (keine Objekt-ID in der URL). Die Bilder sind wie Gravatare öffentlich.
- **Graph-Aufrufe:** nur `https://graph.microsoft.com/v1.0/`; Paging-Links werden auf diesen Präfix geprüft, IDs sind GUIDs.
- **Ausgabe:** Protokoll und Profilfelder werden escaped ausgegeben; Benutzer-Zeilenaktionen mit Nonce und `edit_user`.
Hinweis für den Betrieb: Personenbezogene Daten (Telefon, Adresse, Foto) nur synchronisieren, wenn sie auf der Website
gebraucht werden; die Auswahl ist bewusst Opt-in (Standard: nur Namen).
## 4. Geprüfte Kontrollen (ohne Befund) ## 4. Geprüfte Kontrollen (ohne Befund)
| Bereich | Kontrolle | Ergebnis | | Bereich | Kontrolle | Ergebnis |
@ -120,7 +148,7 @@ den Site-Betreiber entscheiden lassen. Empfehlung: `https_ssl_verify` nie global
| State-Bindung | HMAC-Schlüssel in DB, Klartext nur in URL; HttpOnly/SameSite=Lax/Secure-Cookie mit separatem Token, Hash im Datensatz; einmalige Einlösung (Delete vor Prüfung); TTL 10 Min. | ✔ Login-CSRF und Replay ausgeschlossen | | State-Bindung | HMAC-Schlüssel in DB, Klartext nur in URL; HttpOnly/SameSite=Lax/Secure-Cookie mit separatem Token, Hash im Datensatz; einmalige Einlösung (Delete vor Prüfung); TTL 10 Min. | ✔ Login-CSRF und Replay ausgeschlossen |
| Token-Austausch | Server-zu-Server, Secret/Assertion nie im Browser; `redirect_uri` fest aus `home_url()` | ✔ | | Token-Austausch | Server-zu-Server, Secret/Assertion nie im Browser; `redirect_uri` fest aus `home_url()` | ✔ |
| ID-Token | Nur `RS256`; `alg=none`/HMAC abgelehnt; `kid` Pflicht; JWKS über HTTPS, Cache 12 h, Refresh bei unbekanntem `kid`; `iss` gegen `tid` gebildet, `aud`, `tid` (Pinning), `exp`/`nbf`/`iat` mit 120 s Toleranz, `nonce` mit `hash_equals` | ✔ 11 Negativtests | | ID-Token | Nur `RS256`; `alg=none`/HMAC abgelehnt; `kid` Pflicht; JWKS über HTTPS, Cache 12 h, Refresh bei unbekanntem `kid`; `iss` gegen `tid` gebildet, `aud`, `tid` (Pinning), `exp`/`nbf`/`iat` mit 120 s Toleranz, `nonce` mit `hash_equals` | ✔ 11 Negativtests |
| Benutzerzuordnung | Kein Provisioning; E-Mail lowercase + `is_email`; Domain-Allowlist; Gruppen-Check fail closed; `oid`-Bindung; Multisite-Mitgliedschaft | ✔ | | Benutzerzuordnung | Login ohne Provisioning (Sync separat, siehe N-7); E-Mail lowercase + `is_email`; Domain-Allowlist; Gruppen-Check fail closed; `oid`-Bindung; Multisite-Mitgliedschaft | ✔ |
| Session | `wp_set_auth_cookie` nach Erfolg (neues Session-Token, keine Fixation); `login_redirect`-Filter; `wp_safe_redirect` überall | ✔ | | Session | `wp_set_auth_cookie` nach Erfolg (neues Session-Token, keine Fixation); `login_redirect`-Filter; `wp_safe_redirect` überall | ✔ |
| Offene Redirects | `redirect_to``wp_validate_redirect`; Custom-Login-URL → `wp_validate_redirect` beim Speichern und beim Lesen | ✔ | | Offene Redirects | `redirect_to``wp_validate_redirect`; Custom-Login-URL → `wp_validate_redirect` beim Speichern und beim Lesen | ✔ |
| SSRF | Tenant nur GUID oder Whitelist-Wort, `rawurlencode`; Graph-Pfade mit `rawurlencode`; keine benutzerkontrollierten Hosts | ✔ | | SSRF | Tenant nur GUID oder Whitelist-Wort, `rawurlencode`; Graph-Pfade mit `rawurlencode`; keine benutzerkontrollierten Hosts | ✔ |

View file

@ -17,6 +17,7 @@ class M365_Login_Admin {
const AJAX_TEST = 'm365_login_test_connection'; const AJAX_TEST = 'm365_login_test_connection';
const AJAX_GROUPS = 'm365_login_search_groups'; const AJAX_GROUPS = 'm365_login_search_groups';
const AJAX_CERT = 'm365_login_certificate'; const AJAX_CERT = 'm365_login_certificate';
const AJAX_SYNC = 'm365_login_sync_run';
const POST_CERT = 'm365_login_download_cert'; const POST_CERT = 'm365_login_download_cert';
const MENU_ICON = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMCAyMCI+PHBhdGggZmlsbD0iYmxhY2siIGQ9Ik0yIDJoNy41djcuNUgyek0xMC41IDJIMTh2Ny41aC03LjV6TTIgMTAuNWg3LjVWMThIMnpNMTAuNSAxMC41SDE4VjE4aC03LjV6Ii8+PC9zdmc+'; const MENU_ICON = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMCAyMCI+PHBhdGggZmlsbD0iYmxhY2siIGQ9Ik0yIDJoNy41djcuNUgyek0xMC41IDJIMTh2Ny41aC03LjV6TTIgMTAuNWg3LjVWMThIMnpNMTAuNSAxMC41SDE4VjE4aC03LjV6Ii8+PC9zdmc+';
const NONCE_TEST = 'm365_login_test'; const NONCE_TEST = 'm365_login_test';
@ -42,6 +43,13 @@ class M365_Login_Admin {
*/ */
private $graph; private $graph;
/**
* User sync.
*
* @var M365_Login_Sync
*/
private $sync;
/** /**
* Screen hook suffix. * Screen hook suffix.
* *
@ -55,11 +63,13 @@ class M365_Login_Admin {
* @param M365_Login_Settings $settings Settings. * @param M365_Login_Settings $settings Settings.
* @param M365_Login_Auth $auth Auth. * @param M365_Login_Auth $auth Auth.
* @param M365_Login_Graph $graph Graph client. * @param M365_Login_Graph $graph Graph client.
* @param M365_Login_Sync $sync User sync.
*/ */
public function __construct( M365_Login_Settings $settings, M365_Login_Auth $auth, M365_Login_Graph $graph ) { public function __construct( M365_Login_Settings $settings, M365_Login_Auth $auth, M365_Login_Graph $graph, M365_Login_Sync $sync ) {
$this->settings = $settings; $this->settings = $settings;
$this->auth = $auth; $this->auth = $auth;
$this->graph = $graph; $this->graph = $graph;
$this->sync = $sync;
add_action( 'admin_menu', array( $this, 'menu' ) ); add_action( 'admin_menu', array( $this, 'menu' ) );
add_action( 'admin_init', array( $this, 'register' ) ); add_action( 'admin_init', array( $this, 'register' ) );
@ -67,6 +77,7 @@ class M365_Login_Admin {
add_action( 'wp_ajax_' . self::AJAX_TEST, array( $this, 'ajax_test_connection' ) ); add_action( 'wp_ajax_' . self::AJAX_TEST, array( $this, 'ajax_test_connection' ) );
add_action( 'wp_ajax_' . self::AJAX_GROUPS, array( $this, 'ajax_search_groups' ) ); add_action( 'wp_ajax_' . self::AJAX_GROUPS, array( $this, 'ajax_search_groups' ) );
add_action( 'wp_ajax_' . self::AJAX_CERT, array( $this, 'ajax_certificate' ) ); add_action( 'wp_ajax_' . self::AJAX_CERT, array( $this, 'ajax_certificate' ) );
add_action( 'wp_ajax_' . self::AJAX_SYNC, array( $this, 'ajax_sync' ) );
add_action( 'admin_post_' . self::POST_CERT, array( $this, 'download_certificate' ) ); add_action( 'admin_post_' . self::POST_CERT, array( $this, 'download_certificate' ) );
add_action( 'update_option_' . M365_LOGIN_OPTION, array( $this->graph, 'flush_token' ) ); add_action( 'update_option_' . M365_LOGIN_OPTION, array( $this->graph, 'flush_token' ) );
add_action( 'admin_notices', array( $this, 'setup_notice' ) ); add_action( 'admin_notices', array( $this, 'setup_notice' ) );
@ -108,6 +119,7 @@ class M365_Login_Admin {
'connection' => __( 'Connection', 'm365-login' ), 'connection' => __( 'Connection', 'm365-login' ),
'button' => __( 'Button', 'm365-login' ), 'button' => __( 'Button', 'm365-login' ),
'security' => __( 'Security', 'm365-login' ), 'security' => __( 'Security', 'm365-login' ),
'sync' => __( 'User sync', 'm365-login' ),
); );
} }
@ -212,6 +224,7 @@ class M365_Login_Admin {
'action' => self::AJAX_TEST, 'action' => self::AJAX_TEST,
'groupAction' => self::AJAX_GROUPS, 'groupAction' => self::AJAX_GROUPS,
'certAction' => self::AJAX_CERT, 'certAction' => self::AJAX_CERT,
'syncAction' => self::AJAX_SYNC,
'defaultLogo' => M365_Login_Button::microsoft_logo(), 'defaultLogo' => M365_Login_Button::microsoft_logo(),
'i18n' => array( 'i18n' => array(
'chooseIcon' => __( 'Choose button icon', 'm365-login' ), 'chooseIcon' => __( 'Choose button icon', 'm365-login' ),
@ -229,6 +242,11 @@ class M365_Login_Admin {
'generating' => __( 'Generating a 3072-bit key pair, this takes a moment…', 'm365-login' ), 'generating' => __( 'Generating a 3072-bit key pair, this takes a moment…', 'm365-login' ),
'confirmCert' => __( 'Replace the stored certificate? Sign-in stops working until the new certificate is uploaded to Entra ID.', 'm365-login' ), 'confirmCert' => __( 'Replace the stored certificate? Sign-in stops working until the new certificate is uploaded to Entra ID.', 'm365-login' ),
'confirmCertRemove' => __( 'Remove the stored certificate when saving? Sign-in with the certificate method stops working.', 'm365-login' ), 'confirmCertRemove' => __( 'Remove the stored certificate when saving? Sign-in with the certificate method stops working.', 'm365-login' ),
'syncRunning' => __( 'Sync is running, this can take a while for large directories…', 'm365-login' ),
'confirmSync' => __( 'Run the sync now with the saved settings? Accounts are created, updated and possibly deactivated or deleted. Tip: run a dry run first.', 'm365-login' ),
'syncFailed' => __( 'The request failed or timed out. Reload the page in a few minutes to see the report; for very large directories use "wp m365-login sync" (WP-CLI).', 'm365-login' ),
'unsaved' => __( 'You have unsaved changes. The sync uses the saved settings save first.', 'm365-login' ),
'moveUp' => __( 'Move up', 'm365-login' ),
), ),
) )
); );
@ -332,6 +350,162 @@ class M365_Login_Admin {
); );
} }
/**
* AJAX: run the user sync (dry run or live) with the saved settings.
*/
public function ajax_sync() {
check_ajax_referer( self::NONCE_TEST, 'nonce' );
if ( ! current_user_can( 'manage_options' ) || ! current_user_can( 'create_users' ) ) {
wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'm365-login' ) ), 403 );
}
$op = isset( $_POST['op'] ) ? sanitize_key( wp_unslash( $_POST['op'] ) ) : '';
$report = $this->sync->run( 'live' !== $op, 'manual' );
wp_send_json_success( array( 'html' => $this->report_markup( $report ) ) );
}
/**
* Markup of a sync report.
*
* @param array|null $report Report.
* @return string
*/
private function report_markup( $report ) {
if ( ! is_array( $report ) ) {
return '<p class="m365-sync-report__empty">' . esc_html__( 'The sync has not run yet.', 'm365-login' ) . '</p>';
}
$statuses = array(
'ok' => __( 'Finished', 'm365-login' ),
'failed' => __( 'Failed', 'm365-login' ),
'aborted' => __( 'Stopped by the safety limit', 'm365-login' ),
'locked' => __( 'Not started', 'm365-login' ),
);
$triggers = array(
'manual' => __( 'started manually', 'm365-login' ),
'cron' => __( 'scheduled', 'm365-login' ),
'cli' => __( 'WP-CLI', 'm365-login' ),
);
$labels = array(
'created' => $report['dry'] ? __( 'would be created', 'm365-login' ) : __( 'created', 'm365-login' ),
'updated' => $report['dry'] ? __( 'would be updated', 'm365-login' ) : __( 'updated', 'm365-login' ),
'linked' => $report['dry'] ? __( 'would be linked', 'm365-login' ) : __( 'linked', 'm365-login' ),
'unchanged' => __( 'unchanged', 'm365-login' ),
'disabled' => $report['dry'] ? __( 'would be deactivated', 'm365-login' ) : __( 'deactivated', 'm365-login' ),
'enabled' => $report['dry'] ? __( 'would be reactivated', 'm365-login' ) : __( 'reactivated', 'm365-login' ),
'deleted' => $report['dry'] ? __( 'would be deleted', 'm365-login' ) : __( 'deleted', 'm365-login' ),
'photos' => __( 'photos', 'm365-login' ),
'skipped' => __( 'skipped', 'm365-login' ),
'errors' => __( 'errors', 'm365-login' ),
);
$status = isset( $statuses[ $report['status'] ] ) ? $statuses[ $report['status'] ] : $report['status'];
$class = 'ok' === $report['status'] ? 'is-ok' : 'is-bad';
$when = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), (int) $report['started'] );
ob_start();
?>
<div class="m365-sync-report">
<p class="m365-sync-report__head">
<span class="m365-sync-report__status <?php echo esc_attr( $class ); ?>"><?php echo esc_html( $status ); ?></span>
<?php if ( $report['dry'] ) : ?>
<span class="m365-sync-report__dry"><?php esc_html_e( 'Dry run nothing was changed', 'm365-login' ); ?></span>
<?php endif; ?>
<span class="m365-sync-report__meta">
<?php
/* translators: 1: date and time, 2: how the run was started, 3: duration in seconds */
echo esc_html( sprintf( __( '%1$s, %2$s, %3$d s', 'm365-login' ), $when, isset( $triggers[ $report['trigger'] ] ) ? $triggers[ $report['trigger'] ] : $report['trigger'], max( 0, (int) $report['finished'] - (int) $report['started'] ) ) );
?>
</span>
</p>
<ul class="m365-sync-report__counts">
<?php foreach ( $labels as $key => $label ) : ?>
<?php
$count = isset( $report['counts'][ $key ] ) ? (int) $report['counts'][ $key ] : 0;
if ( 0 === $count && ! in_array( $key, array( 'created', 'updated', 'disabled' ), true ) ) {
continue;
}
?>
<li class="m365-sync-report__count m365-sync-report__count--<?php echo esc_attr( $key ); ?>"><strong><?php echo esc_html( number_format_i18n( $count ) ); ?></strong> <?php echo esc_html( $label ); ?></li>
<?php endforeach; ?>
</ul>
<?php if ( ! empty( $report['log'] ) ) : ?>
<details class="m365-sync-report__details" <?php echo 'ok' !== $report['status'] || $report['dry'] ? 'open' : ''; ?>>
<?php /* translators: %d: number of log entries */ ?>
<summary><?php echo esc_html( sprintf( _n( 'Log (%d entry)', 'Log (%d entries)', count( $report['log'] ), 'm365-login' ), count( $report['log'] ) ) ); ?></summary>
<ul class="m365-sync-log">
<?php foreach ( $report['log'] as $entry ) : ?>
<li class="m365-sync-log__item is-<?php echo esc_attr( $entry['level'] ); ?>"><?php echo esc_html( $entry['message'] ); ?></li>
<?php endforeach; ?>
</ul>
</details>
<?php endif; ?>
</div>
<?php
return (string) ob_get_clean();
}
/**
* Renders an Entra group picker.
*
* @param string $key Option key the picker writes to.
* @param array $selected Selected groups: id => name (chips) or id => array( name, role ) (roles).
* @param string $mode 'chips' or 'roles'.
* @param string $empty_text Text shown when nothing is selected.
*/
private function group_picker( $key, $selected, $mode, $empty_text ) {
$configured = $this->settings->is_configured();
$base = M365_LOGIN_OPTION . '[' . $key . ']';
$id = 'm365-picker-' . str_replace( '_', '-', $key );
?>
<div class="m365-picker" data-field="<?php echo esc_attr( $key ); ?>" data-mode="<?php echo esc_attr( $mode ); ?>">
<div class="m365-field">
<label for="<?php echo esc_attr( $id ); ?>"><?php esc_html_e( 'Search groups', 'm365-login' ); ?></label>
<div class="m365-field__row">
<input type="search" id="<?php echo esc_attr( $id ); ?>" class="regular-text m365-picker__query" placeholder="<?php esc_attr_e( 'Type a group name or paste an object ID…', 'm365-login' ); ?>" autocomplete="off" <?php disabled( ! $configured ); ?> />
<button type="button" class="button m365-picker__search" <?php disabled( ! $configured ); ?>><?php esc_html_e( 'Search', 'm365-login' ); ?></button>
</div>
<?php if ( ! $configured ) : ?>
<p class="description"><?php esc_html_e( 'Save the connection settings first, then search for groups.', 'm365-login' ); ?></p>
<?php else : ?>
<p class="description"><?php esc_html_e( 'Needs the application permission "GroupMember.Read.All" with admin consent. Without it you can still paste group object IDs.', 'm365-login' ); ?></p>
<?php endif; ?>
<div class="m365-group-results m365-picker__results" hidden></div>
</div>
<div class="m365-field">
<span class="m365-field__label"><?php esc_html_e( 'Selected groups', 'm365-login' ); ?></span>
<ul class="m365-group-list m365-picker__list<?php echo 'roles' === $mode ? ' m365-group-list--roles' : ''; ?>" data-empty="<?php echo esc_attr( $empty_text ); ?>">
<?php foreach ( $selected as $gid => $value ) : ?>
<?php $gname = 'roles' === $mode ? $value['name'] : $value; ?>
<li class="m365-group-chip<?php echo 'roles' === $mode ? ' m365-group-chip--role' : ''; ?>" data-id="<?php echo esc_attr( $gid ); ?>">
<?php if ( 'roles' === $mode ) : ?>
<button type="button" class="m365-group-chip__up" aria-label="<?php esc_attr_e( 'Move up', 'm365-login' ); ?>">&uarr;</button>
<?php endif; ?>
<span class="m365-group-chip__name"><?php echo esc_html( $gname ); ?></span>
<code class="m365-group-chip__id"><?php echo esc_html( $gid ); ?></code>
<?php if ( 'roles' === $mode ) : ?>
<span class="m365-group-chip__arrow" aria-hidden="true">&rarr;</span>
<select name="<?php echo esc_attr( $base . '[' . $gid . '][role]' ); ?>" aria-label="<?php esc_attr_e( 'WordPress role', 'm365-login' ); ?>">
<?php wp_dropdown_roles( $value['role'] ); ?>
</select>
<input type="hidden" name="<?php echo esc_attr( $base . '[' . $gid . '][name]' ); ?>" value="<?php echo esc_attr( $gname ); ?>" />
<?php else : ?>
<input type="hidden" name="<?php echo esc_attr( $base . '[' . $gid . ']' ); ?>" value="<?php echo esc_attr( $gname ); ?>" />
<?php endif; ?>
<button type="button" class="m365-group-chip__remove" aria-label="<?php esc_attr_e( 'Remove', 'm365-login' ); ?>">&times;</button>
</li>
<?php endforeach; ?>
</ul>
<?php if ( 'roles' === $mode ) : ?>
<select class="m365-picker__role-template" hidden disabled>
<?php wp_dropdown_roles( 'editor' ); ?>
</select>
<?php endif; ?>
</div>
</div>
<?php
}
/** /**
* Sends the public certificate as a .cer download (never the private key). * Sends the public certificate as a .cer download (never the private key).
*/ */
@ -356,6 +530,175 @@ class M365_Login_Admin {
exit; exit;
} }
/**
* Renders the "User sync" tab.
*
* @param array $s Settings.
*/
private function render_sync_panel( $s ) {
$field = function ( $key ) {
return esc_attr( M365_LOGIN_OPTION . '[' . $key . ']' );
};
$configured = $this->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' ),
);
?>
<section class="m365-admin__panel" data-panel="sync">
<div class="m365-card">
<h2 class="m365-card__title"><?php esc_html_e( 'Import users from Microsoft 365', 'm365-login' ); ?></h2>
<p class="m365-card__intro"><?php esc_html_e( 'Creates a WordPress account for every Microsoft 365 user in scope, links existing accounts by e-mail address, keeps roles and profile fields up to date and deactivates or deletes accounts that were disabled or removed in Microsoft 365. New accounts get a random password and no e-mail; people sign in with the Microsoft button.', 'm365-login' ); ?></p>
<label class="m365-check m365-check--block">
<input type="checkbox" name="<?php echo $field( 'sync_enabled' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['sync_enabled'] ); ?> />
<span>
<strong><?php esc_html_e( 'Run the sync automatically', 'm365-login' ); ?></strong>
<em><?php esc_html_e( 'Uses WP-Cron, which runs when the site receives visits. For exact timing, trigger wp-cron.php from a real cron job or run "wp m365-login sync".', 'm365-login' ); ?></em>
</span>
</label>
<div class="m365-grid">
<div class="m365-field">
<label for="m365-sync-interval"><?php esc_html_e( 'Interval', 'm365-login' ); ?></label>
<select id="m365-sync-interval" name="<?php echo $field( 'sync_interval' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>">
<option value="hourly" <?php selected( $s['sync_interval'], 'hourly' ); ?>><?php esc_html_e( 'Hourly', 'm365-login' ); ?></option>
<option value="twicedaily" <?php selected( $s['sync_interval'], 'twicedaily' ); ?>><?php esc_html_e( 'Twice daily', 'm365-login' ); ?></option>
<option value="daily" <?php selected( $s['sync_interval'], 'daily' ); ?>><?php esc_html_e( 'Daily', 'm365-login' ); ?></option>
</select>
<?php if ( $s['sync_enabled'] && $next ) : ?>
<?php /* translators: %s: date and time */ ?>
<p class="description"><?php echo esc_html( sprintf( __( 'Next run: %s', 'm365-login' ), wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $next ) ) ); ?></p>
<?php endif; ?>
</div>
</div>
<label class="m365-check m365-check--block">
<input type="checkbox" name="<?php echo $field( 'sync_guests' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['sync_guests'] ); ?> />
<span>
<strong><?php esc_html_e( 'Also import guest users (B2B)', 'm365-login' ); ?></strong>
<em><?php esc_html_e( 'Guests are external people invited into your tenant. Off by default.', 'm365-login' ); ?></em>
</span>
</label>
<h3 class="m365-card__subtitle"><?php esc_html_e( 'Which users? (optional)', 'm365-login' ); ?></h3>
<p class="description"><?php esc_html_e( 'Limit the import to members of these groups (nested memberships count). Without groups, every user of the tenant is imported. The e-mail domain allow-list on the Security tab applies as well.', 'm365-login' ); ?></p>
<?php $this->group_picker( 'sync_scope_groups', $this->settings->sync_scope_groups(), 'chips', __( 'No groups selected all users of the tenant are imported.', 'm365-login' ) ); ?>
</div>
<div class="m365-card">
<h2 class="m365-card__title"><?php esc_html_e( 'Roles', 'm365-login' ); ?></h2>
<div class="m365-field">
<label for="m365-sync-role"><?php esc_html_e( 'Default role', 'm365-login' ); ?></label>
<select id="m365-sync-role" name="<?php echo $field( 'sync_default_role' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>">
<?php wp_dropdown_roles( $s['sync_default_role'] ); ?>
</select>
<p class="description"><?php esc_html_e( 'Every imported user gets this role. The sync manages the roles of imported accounts manual role changes are overwritten on the next run.', 'm365-login' ); ?></p>
</div>
<h3 class="m365-card__subtitle"><?php esc_html_e( 'Additional roles from Microsoft 365 groups', 'm365-login' ); ?></h3>
<p class="description"><?php esc_html_e( 'Members of a group (nested memberships count) get the role next to it. If a person leaves the group, the role is removed again on the next sync.', 'm365-login' ); ?></p>
<?php $this->group_picker( 'sync_role_map', $this->settings->sync_role_map(), 'roles', __( 'No group mapping everybody gets the default role.', 'm365-login' ) ); ?>
<div class="m365-field">
<span class="m365-field__label"><?php esc_html_e( 'How are mapped roles applied?', 'm365-login' ); ?></span>
<label class="m365-check">
<input type="radio" name="<?php echo $field( 'sync_role_mode' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="add" <?php checked( $s['sync_role_mode'], 'add' ); ?> />
<?php esc_html_e( 'In addition to the default role (a user can have several roles)', 'm365-login' ); ?>
</label>
<label class="m365-check">
<input type="radio" name="<?php echo $field( 'sync_role_mode' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="replace" <?php checked( $s['sync_role_mode'], 'replace' ); ?> />
<?php esc_html_e( 'Instead of the default role the first matching group in the list wins (use ↑ to reorder)', 'm365-login' ); ?>
</label>
</div>
<label class="m365-check m365-check--block">
<input type="checkbox" name="<?php echo $field( 'sync_manage_existing' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['sync_manage_existing'] ); ?> />
<span>
<strong><?php esc_html_e( 'Also manage the roles of accounts that existed before the sync', 'm365-login' ); ?></strong>
<em><?php esc_html_e( 'Off: existing accounts are only linked and get their profile fields updated; their roles stay as they are. Administrators that existed before the sync and your own account are never changed.', 'm365-login' ); ?></em>
</span>
</label>
</div>
<div class="m365-card">
<h2 class="m365-card__title"><?php esc_html_e( 'Profile fields', 'm365-login' ); ?></h2>
<p class="m365-card__intro"><?php esc_html_e( 'Selected Microsoft 365 attributes are copied into the WordPress profile on every sync (Microsoft 365 wins). Name fields go into the standard profile fields, everything else into user meta keys starting with "m365_" usable by themes and other plugins and is shown on the profile screen.', 'm365-login' ); ?></p>
<div class="m365-attributes">
<?php foreach ( M365_Login_Sync::attributes() as $key => $attribute ) : ?>
<label class="m365-check">
<input type="checkbox" name="<?php echo $field( 'sync_attributes' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>[]" value="<?php echo esc_attr( $key ); ?>" <?php checked( in_array( $key, (array) $s['sync_attributes'], true ) ); ?> />
<span><?php echo esc_html( $attribute['label'] ); ?> <code><?php echo esc_html( $key ); ?></code></span>
</label>
<?php endforeach; ?>
</div>
<p class="description"><?php esc_html_e( 'Profile photos are stored in wp-content/uploads/m365-login-avatars/ and replace the Gravatar. They are checked about once a day per user.', 'm365-login' ); ?></p>
</div>
<div class="m365-card">
<h2 class="m365-card__title"><?php esc_html_e( 'Disabled and deleted Microsoft 365 accounts', 'm365-login' ); ?></h2>
<p class="m365-card__intro"><?php esc_html_e( 'Applies to WordPress accounts linked to a Microsoft account (imported, or signed in with Microsoft at least once). Deactivated accounts cannot sign in at all not with Microsoft, a password or an application password and are signed out immediately. When the person is active in Microsoft 365 again, the sync reactivates the account.', 'm365-login' ); ?></p>
<?php foreach ( $selects as $key => $label ) : ?>
<div class="m365-field">
<label for="m365-<?php echo esc_attr( $key ); ?>"><?php echo esc_html( $label ); ?></label>
<select id="m365-<?php echo esc_attr( $key ); ?>" name="<?php echo $field( $key ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>">
<?php foreach ( $actions as $value => $text ) : ?>
<option value="<?php echo esc_attr( $value ); ?>" <?php selected( $s[ $key ], $value ); ?>><?php echo esc_html( $text ); ?></option>
<?php endforeach; ?>
</select>
<?php if ( 'sync_scope_action' === $key ) : ?>
<p class="description"><?php esc_html_e( 'Only relevant when the import is limited to groups.', 'm365-login' ); ?></p>
<?php endif; ?>
</div>
<?php endforeach; ?>
<div class="m365-field">
<label for="m365-sync-reassign"><?php esc_html_e( 'Posts of deleted accounts go to', 'm365-login' ); ?></label>
<?php
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- core function, escapes its output.
echo wp_dropdown_users(
array(
'name' => 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,
)
);
?>
<p class="description"><?php esc_html_e( 'Required for "Delete". Without a user, accounts are deactivated instead, so no content is ever lost.', 'm365-login' ); ?></p>
</div>
<p class="m365-warning"><?php esc_html_e( 'Safety stop: if a run would deactivate or delete more than 20 % of the linked accounts (at least 5), nothing is deactivated or deleted and the run is reported as stopped. A failed Microsoft Graph request also stops the run before anything is deactivated.', 'm365-login' ); ?></p>
</div>
<div class="m365-card m365-card--accent">
<h2 class="m365-card__title"><?php esc_html_e( 'Run the sync', 'm365-login' ); ?></h2>
<p><?php esc_html_e( 'The run uses the saved settings. Start with a dry run: it reads Microsoft 365 and lists what would change, without changing anything.', 'm365-login' ); ?></p>
<div class="m365-field__row">
<button type="button" class="button button-primary m365-sync-run" data-op="dry" <?php disabled( ! $configured ); ?>><?php esc_html_e( 'Dry run', 'm365-login' ); ?></button>
<button type="button" class="button m365-sync-run" data-op="live" <?php disabled( ! $configured ); ?>><?php esc_html_e( 'Sync now', 'm365-login' ); ?></button>
</div>
<p class="description"><?php esc_html_e( 'Required application permissions (Microsoft Graph, admin consent): User.Read.All, and GroupMember.Read.All when groups are used.', 'm365-login' ); ?></p>
<div id="m365-sync-report" class="m365-sync-report-wrap">
<?php echo $this->report_markup( M365_Login_Sync::last_report() ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in report_markup(). ?>
</div>
</div>
</section>
<?php
}
/** /**
* Renders the settings screen. * Renders the settings screen.
*/ */
@ -695,7 +1038,7 @@ class M365_Login_Admin {
<section class="m365-admin__panel" data-panel="security"> <section class="m365-admin__panel" data-panel="security">
<div class="m365-card"> <div class="m365-card">
<h2 class="m365-card__title"><?php esc_html_e( 'User matching & hardening', 'm365-login' ); ?></h2> <h2 class="m365-card__title"><?php esc_html_e( 'User matching & hardening', 'm365-login' ); ?></h2>
<p class="m365-card__intro"><?php esc_html_e( 'Users are never created automatically. A Microsoft sign-in only succeeds when a WordPress user with the same e-mail address already exists.', 'm365-login' ); ?></p> <p class="m365-card__intro"><?php esc_html_e( 'Sign-in never creates users. A Microsoft sign-in only succeeds when a WordPress user with the same e-mail address already exists created by hand or imported by the user sync.', 'm365-login' ); ?></p>
<label class="m365-check m365-check--block"> <label class="m365-check m365-check--block">
<input type="checkbox" name="<?php echo $field( 'bind_oid' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['bind_oid'] ); ?> /> <input type="checkbox" name="<?php echo $field( 'bind_oid' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>" value="1" <?php checked( $s['bind_oid'] ); ?> />
@ -732,32 +1075,8 @@ class M365_Login_Admin {
<h2 class="m365-card__title"><?php esc_html_e( 'Allowed Entra groups (optional)', 'm365-login' ); ?></h2> <h2 class="m365-card__title"><?php esc_html_e( 'Allowed Entra groups (optional)', 'm365-login' ); ?></h2>
<p class="m365-card__intro"><?php esc_html_e( 'Only members of at least one of these groups may sign in. Leave empty to allow every matched user. Nested memberships count.', 'm365-login' ); ?></p> <p class="m365-card__intro"><?php esc_html_e( 'Only members of at least one of these groups may sign in. Leave empty to allow every matched user. Nested memberships count.', 'm365-login' ); ?></p>
<?php $this->group_picker( 'allowed_groups', $this->settings->allowed_groups(), 'chips', __( 'No groups selected every matched user may sign in.', 'm365-login' ) ); ?>
<div class="m365-field"> <div class="m365-field">
<label for="m365-group-search"><?php esc_html_e( 'Search groups', 'm365-login' ); ?></label>
<div class="m365-field__row">
<input type="search" id="m365-group-search" class="regular-text" placeholder="<?php esc_attr_e( 'Type a group name or paste an object ID…', 'm365-login' ); ?>" autocomplete="off" <?php disabled( ! $configured ); ?> />
<button type="button" class="button" id="m365-group-search-btn" <?php disabled( ! $configured ); ?>><?php esc_html_e( 'Search', 'm365-login' ); ?></button>
</div>
<?php if ( ! $configured ) : ?>
<p class="description"><?php esc_html_e( 'Save the connection settings first, then search for groups.', 'm365-login' ); ?></p>
<?php else : ?>
<p class="description"><?php esc_html_e( 'Needs the application permission "GroupMember.Read.All" with admin consent. Without it you can still paste group object IDs.', 'm365-login' ); ?></p>
<?php endif; ?>
<div id="m365-group-results" class="m365-group-results" hidden></div>
</div>
<div class="m365-field">
<span class="m365-field__label"><?php esc_html_e( 'Selected groups', 'm365-login' ); ?></span>
<ul id="m365-group-list" class="m365-group-list" data-empty="<?php esc_attr_e( 'No groups selected every matched user may sign in.', 'm365-login' ); ?>">
<?php foreach ( $this->settings->allowed_groups() as $gid => $gname ) : ?>
<li class="m365-group-chip" data-id="<?php echo esc_attr( $gid ); ?>">
<span class="m365-group-chip__name"><?php echo esc_html( $gname ); ?></span>
<code class="m365-group-chip__id"><?php echo esc_html( $gid ); ?></code>
<input type="hidden" name="<?php echo esc_attr( $option . '[allowed_groups][' . $gid . ']' ); ?>" value="<?php echo esc_attr( $gname ); ?>" />
<button type="button" class="m365-group-chip__remove" aria-label="<?php esc_attr_e( 'Remove', 'm365-login' ); ?>">&times;</button>
</li>
<?php endforeach; ?>
</ul>
<p class="description"><?php esc_html_e( 'Membership is read from the "groups" claim of the ID token when present; otherwise the plugin asks Microsoft Graph (application permission "User.Read.All" or "Directory.Read.All"). If neither works, the sign-in is refused.', 'm365-login' ); ?></p> <p class="description"><?php esc_html_e( 'Membership is read from the "groups" claim of the ID token when present; otherwise the plugin asks Microsoft Graph (application permission "User.Read.All" or "Directory.Read.All"). If neither works, the sign-in is refused.', 'm365-login' ); ?></p>
</div> </div>
</div> </div>
@ -807,11 +1126,13 @@ class M365_Login_Admin {
<li><?php esc_html_e( 'OpenID Connect authorization code flow with PKCE (S256) no tokens ever pass through the browser.', 'm365-login' ); ?></li> <li><?php esc_html_e( 'OpenID Connect authorization code flow with PKCE (S256) no tokens ever pass through the browser.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Single-use state and nonce values bound to the browser via an HttpOnly cookie (CSRF and replay protection).', 'm365-login' ); ?></li> <li><?php esc_html_e( 'Single-use state and nonce values bound to the browser via an HttpOnly cookie (CSRF and replay protection).', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'ID token signature verified against Microsofts published signing keys; issuer, audience, tenant, expiry and nonce are checked.', 'm365-login' ); ?></li> <li><?php esc_html_e( 'ID token signature verified against Microsofts published signing keys; issuer, audience, tenant, expiry and nonce are checked.', 'm365-login' ); ?></li>
<li><?php esc_html_e( 'Client secret encrypted at rest; no accounts are created, no passwords are changed.', 'm365-login' ); ?></li> <li><?php esc_html_e( 'Client secret encrypted at rest; sign-in never creates accounts or changes passwords.', 'm365-login' ); ?></li>
</ul> </ul>
</div> </div>
</section> </section>
<?php $this->render_sync_panel( $s ); ?>
<div class="m365-admin__actions"> <div class="m365-admin__actions">
<?php submit_button( __( 'Save changes', 'm365-login' ), 'primary large', 'submit', false ); ?> <?php submit_button( __( 'Save changes', 'm365-login' ), 'primary large', 'submit', false ); ?>
</div> </div>
@ -846,7 +1167,7 @@ class M365_Login_Admin {
<li><?php esc_html_e( 'Optional: restrict who may use the app under Enterprise applications → your app → Properties → "Assignment required" = Yes, then assign users/groups.', 'm365-login' ); ?></li> <li><?php esc_html_e( 'Optional: restrict who may use the app under Enterprise applications → your app → Properties → "Assignment required" = Yes, then assign users/groups.', 'm365-login' ); ?></li>
</ol> </ol>
<p class="description"><?php esc_html_e( 'Required API permission: openid, profile, email (delegated) granted by default.', 'm365-login' ); ?></p> <p class="description"><?php esc_html_e( 'Required API permission: openid, profile, email (delegated) granted by default.', 'm365-login' ); ?></p>
<p class="description"><?php esc_html_e( 'Optional, for group restrictions: application permissions GroupMember.Read.All and User.Read.All (Microsoft Graph) with admin consent.', 'm365-login' ); ?></p> <p class="description"><?php esc_html_e( 'Optional, for group restrictions and the user sync: application permissions GroupMember.Read.All and User.Read.All (Microsoft Graph) with admin consent.', 'm365-login' ); ?></p>
</div> </div>
<div class="m365-card m365-card--muted"> <div class="m365-card m365-card--muted">

View file

@ -441,6 +441,10 @@ class M365_Login_Auth {
$this->fail( 'no_user' ); $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'] ) : ''; $oid = isset( $claims['oid'] ) && is_string( $claims['oid'] ) ? strtolower( $claims['oid'] ) : '';
// Entra group restriction. // Entra group restriction.
@ -857,6 +861,7 @@ class M365_Login_Auth {
'fallback_invalid' => __( 'The fallback key is not valid.', 'm365-login' ), 'fallback_invalid' => __( 'The fallback key is not valid.', 'm365-login' ),
'fallback_locked' => __( 'Too many attempts. Please wait 15 minutes.', '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' ), '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' ),
); );
} }

View file

@ -8,7 +8,7 @@
defined( 'ABSPATH' ) || exit; 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 { class M365_Login_Graph {
@ -99,15 +99,23 @@ class M365_Login_Graph {
} }
/** /**
* Performs an authenticated Graph request. * 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 $method HTTP method.
* @param string $path Path relative to the v1.0 base (with query string). * @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|null $json JSON body for POST requests.
* @param array $headers Extra headers. * @param array $headers Extra headers.
* @return array|WP_Error Decoded JSON. * @return array|WP_Error Response array from wp_remote_request().
*/ */
private function request( $method, $path, $json = null, $headers = array() ) { 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(); $token = $this->app_token();
if ( is_wp_error( $token ) ) { if ( is_wp_error( $token ) ) {
return $token; return $token;
@ -129,7 +137,35 @@ class M365_Login_Graph {
$args['body'] = wp_json_encode( $json ); $args['body'] = wp_json_encode( $json );
} }
$response = wp_remote_request( self::GRAPH_BASE . $path, $args ); $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.
*
* @param string $method HTTP method.
* @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. Errors carry array( 'status' => HTTP code ) as data.
*/
private function request( $method, $path, $json = null, $headers = array() ) {
$response = $this->raw_request( $method, $path, $json, $headers );
if ( is_wp_error( $response ) ) { if ( is_wp_error( $response ) ) {
return $response; return $response;
} }
@ -137,18 +173,156 @@ class M365_Login_Graph {
$code = (int) wp_remote_retrieve_response_code( $response ); $code = (int) wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true ); $body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( 401 === $code ) {
$this->flush_token();
}
if ( $code < 200 || $code >= 300 || ! is_array( $body ) ) { if ( $code < 200 || $code >= 300 || ! is_array( $body ) ) {
$graph_code = isset( $body['error']['code'] ) ? (string) $body['error']['code'] : 'HTTP ' . $code; return $this->error_from( $code, $body );
$message = isset( $body['error']['message'] ) ? (string) $body['error']['message'] : '';
return new WP_Error( 'graph_' . sanitize_key( $graph_code ), $graph_code . ( $message ? ': ' . $message : '' ) );
} }
return $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. * Searches groups by display name.
* *

View file

@ -64,6 +64,20 @@ class M365_Login_Settings {
// Custom login pages. // Custom login pages.
'custom_login_url' => '', 'custom_login_url' => '',
'inject_form' => 1, // Add the button to wp_login_form() output. '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; return $this->cache;
} }
/**
* Drops the cached settings (after the option was written).
*/
public function flush() {
$this->cache = null;
}
/** /**
* Returns a single setting. * Returns a single setting.
* *
@ -309,7 +330,47 @@ class M365_Login_Settings {
* @return array * @return array
*/ */
public function allowed_groups() { 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(); $out = array();
if ( is_array( $raw ) ) { if ( is_array( $raw ) ) {
foreach ( $raw as $id => $name ) { foreach ( $raw as $id => $name ) {
@ -490,21 +551,7 @@ class M365_Login_Settings {
$out['allowed_domains'] = trim( (string) $domains ); $out['allowed_domains'] = trim( (string) $domains );
// Allowed groups: GUID => name. // Allowed groups: GUID => name.
$groups = array(); $out['allowed_groups'] = self::sanitize_group_list( isset( $input['allowed_groups'] ) ? $input['allowed_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;
// Button-only mode + fallback key. // Button-only mode + fallback key.
$out['button_only'] = empty( $input['button_only'] ) ? 0 : 1; $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'] ) ) : ''; $divider = isset( $input['divider_text'] ) ? sanitize_text_field( wp_unslash( $input['divider_text'] ) ) : '';
$out['divider_text'] = mb_substr( $divider, 0, 40 ); $out['divider_text'] = mb_substr( $divider, 0, 40 );
$out = $this->sanitize_sync( $input, $out );
$this->cache = null; $this->cache = null;
return $out; 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. * Checks a GUID.
* *

File diff suppressed because it is too large Load diff

View file

@ -40,6 +40,13 @@ final class M365_Login {
*/ */
public $graph; public $graph;
/**
* User sync component.
*
* @var M365_Login_Sync
*/
public $sync;
/** /**
* Login button component. * Login button component.
* *
@ -75,10 +82,11 @@ final class M365_Login {
$this->settings = new M365_Login_Settings(); $this->settings = new M365_Login_Settings();
$this->graph = new M365_Login_Graph( $this->settings ); $this->graph = new M365_Login_Graph( $this->settings );
$this->auth = new M365_Login_Auth( $this->settings, $this->graph ); $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 ); $this->button = new M365_Login_Button( $this->settings );
if ( is_admin() ) { 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' ) ); add_filter( 'plugin_action_links_' . plugin_basename( M365_LOGIN_FILE ), array( $this, 'action_links' ) );

Binary file not shown.

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -2,8 +2,8 @@
/** /**
* Plugin Name: M365 Login * Plugin Name: M365 Login
* Plugin URI: https://github.com/friloo/wp-m365-login * Plugin URI: https://github.com/friloo/wp-m365-login
* Description: Adds a customisable "Sign in with Microsoft" button to the WordPress login page. Existing users are matched by e-mail address via Microsoft Entra ID (OpenID Connect, PKCE). * Description: Adds a customisable "Sign in with Microsoft" button to the WordPress login page. Users are matched by e-mail address via Microsoft Entra ID (OpenID Connect, PKCE); an optional user sync imports Microsoft 365 users with roles and profile fields.
* Version: 1.0.0 * Version: 1.1.0
* Requires at least: 6.0 * Requires at least: 6.0
* Requires PHP: 7.4 * Requires PHP: 7.4
* Author: friloo * Author: friloo
@ -16,7 +16,7 @@
defined( 'ABSPATH' ) || exit; defined( 'ABSPATH' ) || exit;
define( 'M365_LOGIN_VERSION', '1.0.0' ); define( 'M365_LOGIN_VERSION', '1.1.0' );
define( 'M365_LOGIN_FILE', __FILE__ ); define( 'M365_LOGIN_FILE', __FILE__ );
define( 'M365_LOGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'M365_LOGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'M365_LOGIN_URL', plugin_dir_url( __FILE__ ) ); define( 'M365_LOGIN_URL', plugin_dir_url( __FILE__ ) );
@ -28,11 +28,13 @@ require_once M365_LOGIN_DIR . 'includes/class-m365-login-jwt.php';
require_once M365_LOGIN_DIR . 'includes/class-m365-login-certificate.php'; require_once M365_LOGIN_DIR . 'includes/class-m365-login-certificate.php';
require_once M365_LOGIN_DIR . 'includes/class-m365-login-graph.php'; require_once M365_LOGIN_DIR . 'includes/class-m365-login-graph.php';
require_once M365_LOGIN_DIR . 'includes/class-m365-login-auth.php'; require_once M365_LOGIN_DIR . 'includes/class-m365-login-auth.php';
require_once M365_LOGIN_DIR . 'includes/class-m365-login-sync.php';
require_once M365_LOGIN_DIR . 'includes/class-m365-login-button.php'; require_once M365_LOGIN_DIR . 'includes/class-m365-login-button.php';
require_once M365_LOGIN_DIR . 'includes/class-m365-login-admin.php'; require_once M365_LOGIN_DIR . 'includes/class-m365-login-admin.php';
require_once M365_LOGIN_DIR . 'includes/class-m365-login.php'; require_once M365_LOGIN_DIR . 'includes/class-m365-login.php';
require_once M365_LOGIN_DIR . 'includes/functions.php'; require_once M365_LOGIN_DIR . 'includes/functions.php';
register_activation_hook( __FILE__, array( 'M365_Login', 'activate' ) ); register_activation_hook( __FILE__, array( 'M365_Login', 'activate' ) );
register_deactivation_hook( __FILE__, array( 'M365_Login_Sync', 'unschedule' ) );
add_action( 'plugins_loaded', array( 'M365_Login', 'instance' ) ); add_action( 'plugins_loaded', array( 'M365_Login', 'instance' ) );

View file

@ -4,7 +4,7 @@ Tags: microsoft, entra id, azure ad, sso, login
Requires at least: 6.0 Requires at least: 6.0
Tested up to: 6.9 Tested up to: 6.9
Requires PHP: 7.4 Requires PHP: 7.4
Stable tag: 1.0.0 Stable tag: 1.1.0
License: GPLv2 or later License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html License URI: https://www.gnu.org/licenses/gpl-2.0.html
@ -16,7 +16,8 @@ Adds a customisable "Sign in with Microsoft" button to the login page. Existing
The plugin is deliberately small and strict: The plugin is deliberately small and strict:
* **No user provisioning.** A Microsoft sign-in succeeds only when a WordPress user with the same e-mail address already exists. Nobody gets an account just by having a Microsoft login. * **Sign-in never creates users.** A Microsoft sign-in succeeds only when a WordPress user with the same e-mail address already exists. Nobody gets an account just by having a Microsoft login.
* **Optional user sync.** Import all Microsoft 365 users (or the members of selected groups) as WordPress accounts, assign a default role plus extra roles through a group → role mapping, copy selected profile attributes and the profile photo, and deactivate or delete WordPress accounts whose Microsoft 365 account was disabled or deleted. Runs on demand, on a WP-Cron schedule or with `wp m365-login sync`; a dry run shows every change first.
* **Password login stays available.** The button is an additional option; the normal form is untouched. * **Password login stays available.** The button is an additional option; the normal form is untouched.
* **Fully customisable button.** Change the text, replace the Microsoft logo with your own icon from the media library, pick background, hover, text and border colours, adjust the corner radius, and choose whether the button appears above or below the login form with a live preview. * **Fully customisable button.** Change the text, replace the Microsoft logo with your own icon from the media library, pick background, hover, text and border colours, adjust the corner radius, and choose whether the button appears above or below the login form with a live preview.
* **Entra group restriction.** Search and pick the groups whose members may sign in, right in the settings screen. Membership is checked via the ID token's `groups` claim or Microsoft Graph (nested groups included). * **Entra group restriction.** Search and pick the groups whose members may sign in, right in the settings screen. Membership is checked via the ID token's `groups` claim or Microsoft Graph (nested groups included).
@ -37,6 +38,8 @@ The plugin is deliberately small and strict:
* The **client secret / private key is encrypted at rest** (AES-256-GCM, key derived from your WordPress salts) and never displayed again. * The **client secret / private key is encrypted at rest** (AES-256-GCM, key derived from your WordPress salts) and never displayed again.
* In multi-tenant mode the unverified `email` claim is ignored; matching uses the user principal name (verified domain) only. * In multi-tenant mode the unverified `email` claim is ignored; matching uses the user principal name (verified domain) only.
* Login starts and fallback-key attempts are rate limited per client. * Login starts and fallback-key attempts are rate limited per client.
* The user sync stops before deactivating anything when a Microsoft Graph request fails, treats an account as deleted only when Graph returns 404 for its object ID, refuses to deactivate or delete more than 20 % of the linked accounts in one run, and never touches administrators that existed before the sync or your own account.
* Deactivated accounts lose every sign-in path (Microsoft, password, application passwords) and all sessions immediately.
* Every setting is sanitised, every output escaped, every admin request nonce- and capability-checked. * Every setting is sanitised, every output escaped, every admin request nonce- and capability-checked.
= Developer hooks = = Developer hooks =
@ -47,10 +50,12 @@ The plugin is deliberately small and strict:
* `m365_login_allow_user` filter, return `false` to block a matched user (e.g. group checks). * `m365_login_allow_user` filter, return `false` to block a matched user (e.g. group checks).
* `m365_login_success` action after a successful sign-in, receives the user and verified claims. * `m365_login_success` action after a successful sign-in, receives the user and verified claims.
* `m365_login_block_password_login` filter, return `false` to exempt a password sign-in from button-only mode. * `m365_login_block_password_login` filter, return `false` to exempt a password sign-in from button-only mode.
* `m365_login_sync_attributes`, `m365_login_sync_roles`, `m365_login_sync_new_user_data`, `m365_login_sync_email`, `m365_login_sync_protect_user`, `m365_login_sync_deprovision_limit`, `m365_login_sync_photo_limit`, `m365_login_sync_photo_interval` filters for the user sync.
* `m365_login_sync_user_created`, `m365_login_sync_finished`, `m365_login_user_disabled`, `m365_login_user_enabled` actions for the user sync.
== External services == == External services ==
This plugin connects to **Microsoft identity platform (Microsoft Entra ID)** to authenticate users. It is required for the plugin's only purpose signing users in with their Microsoft account and is only contacted when a user clicks the Microsoft button or when an administrator uses the "Test tenant" button. This plugin connects to **Microsoft identity platform (Microsoft Entra ID)** to authenticate users. It is required for the plugin's main purpose signing users in with their Microsoft account and is only contacted when a user clicks the Microsoft button, when an administrator uses the "Test tenant" button, or when the optional user sync runs.
Endpoints used (all under `https://login.microsoftonline.com/`): Endpoints used (all under `https://login.microsoftonline.com/`):
@ -59,12 +64,14 @@ Endpoints used (all under `https://login.microsoftonline.com/`):
* `/{tenant}/discovery/v2.0/keys` the server downloads Microsoft's public signing keys to verify the ID token. No user data is sent. * `/{tenant}/discovery/v2.0/keys` the server downloads Microsoft's public signing keys to verify the ID token. No user data is sent.
* `/{tenant}/v2.0/.well-known/openid-configuration` fetched only when an administrator clicks "Test tenant". No user data is sent. * `/{tenant}/v2.0/.well-known/openid-configuration` fetched only when an administrator clicks "Test tenant". No user data is sent.
When the optional **group restriction** is configured, the plugin additionally connects to **Microsoft Graph** (`https://graph.microsoft.com/v1.0/`) using an application token obtained from `/{tenant}/oauth2/v2.0/token` (client credentials, client ID and secret are sent): When the optional **group restriction** or the optional **user sync** is used, the plugin additionally connects to **Microsoft Graph** (`https://graph.microsoft.com/v1.0/`) using an application token obtained from `/{tenant}/oauth2/v2.0/token` (client credentials, client ID and secret or signed assertion are sent):
* `/groups` only when an administrator searches for groups in the settings screen. The typed search text is sent. * `/groups` only when an administrator searches for groups in the settings screen. The typed search text is sent.
* `/users/{id}/checkMemberGroups` during sign-in when the ID token carries no usable `groups` claim. The user's Microsoft object ID and the configured group IDs are sent; Microsoft returns which of those groups the user belongs to. * `/users/{id}/checkMemberGroups` during sign-in when the ID token carries no usable `groups` claim. The user's Microsoft object ID and the configured group IDs are sent; Microsoft returns which of those groups the user belongs to.
* `/users`, `/groups/{id}/transitiveMembers`, `/users/{id}` only while the user sync runs (manually, on the configured schedule or via WP-CLI). The configured group IDs and the object IDs of linked accounts are sent; Microsoft returns the users with their account status and the profile attributes selected in the settings.
* `/users/{id}/photos/240x240`, `/users/{id}/photo` only while the user sync runs and "Profile photo" is selected. Returns the user's profile photo.
The plugin receives the user's e-mail address / user principal name, display name and Microsoft object ID from Microsoft and uses them solely to find the matching WordPress account. Nothing else is stored. For sign-in the plugin receives the user's e-mail address / user principal name, display name and Microsoft object ID and uses them solely to find the matching WordPress account. The user sync stores the object ID, the account status and the attributes selected by the administrator (for example name, job title, department, phone numbers, profile photo) in the WordPress user profile; profile photos are saved in `wp-content/uploads/m365-login-avatars/` and are shown publicly wherever WordPress displays avatars.
Microsoft terms and privacy: [Microsoft Services Agreement](https://www.microsoft.com/servicesagreement), [Microsoft Privacy Statement](https://privacy.microsoft.com/privacystatement), [Microsoft identity platform documentation](https://learn.microsoft.com/entra/identity-platform/). Microsoft terms and privacy: [Microsoft Services Agreement](https://www.microsoft.com/servicesagreement), [Microsoft Privacy Statement](https://privacy.microsoft.com/privacystatement), [Microsoft identity platform documentation](https://learn.microsoft.com/entra/identity-platform/).
@ -88,7 +95,15 @@ Both work. A certificate is recommended: the private key stays on your server (e
= Does the plugin create users? = = Does the plugin create users? =
No. Users must already exist in WordPress. The e-mail address is the only link between the Microsoft account and the WordPress account. This is intentional it keeps the administrator in control of who can access the site. Signing in never creates users: they must already exist in WordPress, linked by e-mail address. If you want accounts for your Microsoft 365 users, enable the **User sync** tab: it imports all users (or the members of selected groups) ahead of time, assigns roles and keeps profiles up to date.
= What happens to people who leave the organisation? =
With the user sync, a WordPress account whose Microsoft 365 account is disabled or deleted can be deactivated (no sign-in of any kind, sessions ended) or deleted (content reassigned to a user you pick). If the Microsoft 365 account is enabled again, an account deactivated by the sync is reactivated automatically. Administrators that existed before the sync are never deactivated or deleted automatically.
= Which Microsoft Graph permissions does the user sync need? =
The application permission `User.Read.All` with admin consent, plus `GroupMember.Read.All` when you limit the sync to groups or map groups to roles. A tenant GUID must be configured on the Connection tab.
= Which accounts can sign in? = = Which accounts can sign in? =
@ -120,7 +135,7 @@ Yes. Settings are per site; a user must be a member of the site (or a super admi
= What happens on uninstall? = = What happens on uninstall? =
The settings, cached data and the per-user Microsoft object ID are removed. The settings, cached data, the sync report and schedule, stored profile photos and the per-user plugin data (Microsoft object ID, deactivation status) are removed. Imported accounts and copied profile fields (`m365_*` user meta) are kept. Deactivated accounts become active again, so delete them first if they must stay locked.
== Screenshots == == Screenshots ==
@ -132,10 +147,20 @@ The settings, cached data and the per-user Microsoft object ID are removed.
== Changelog == == Changelog ==
= 1.1.0 =
* New: user sync import Microsoft 365 users (whole tenant or selected groups) with a default role and group → role mapping, selectable profile attributes and profile photos as avatars.
* New: deactivate or delete WordPress accounts whose Microsoft 365 account was disabled or deleted; automatic reactivation; dry run, safety stop and protected administrators.
* New: "Microsoft 365" column, deactivate/reactivate row actions and a read-only Microsoft 365 section on the profile screen.
* New: `wp m365-login sync [--dry-run]` WP-CLI command and scheduled sync via WP-Cron.
* Fix: generating or removing the certificate in the settings did not keep the change and broke a stored client secret.
= 1.0.0 = = 1.0.0 =
* Initial release. * Initial release.
== Upgrade Notice == == Upgrade Notice ==
= 1.1.0 =
Adds an optional Microsoft 365 user sync (import, roles, profile fields, deactivation). Nothing changes until you enable it on the new User sync tab.
= 1.0.0 = = 1.0.0 =
Initial release. Initial release.

View file

@ -18,6 +18,18 @@ function m365_login_uninstall_site() {
global $wpdb; global $wpdb;
delete_option( 'm365_login_settings' ); delete_option( 'm365_login_settings' );
delete_option( 'm365_login_sync_report' );
wp_clear_scheduled_hook( 'm365_login_sync' );
// Synced profile photos (uploads/m365-login-avatars/).
$uploads = wp_get_upload_dir();
$dir = trailingslashit( $uploads['basedir'] ) . 'm365-login-avatars';
if ( is_dir( $dir ) ) {
foreach ( (array) glob( $dir . '/m365-*' ) as $file ) {
wp_delete_file( $file );
}
@rmdir( $dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- best effort, may contain foreign files.
}
// Transients: state records and JWKS cache. // Transients: state records and JWKS cache.
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
@ -40,6 +52,7 @@ if ( is_multisite() ) {
m365_login_uninstall_site(); m365_login_uninstall_site();
} }
// User meta is global. // User meta is global. Imported accounts stay; copied profile fields (m365_*) are kept as ordinary user data.
delete_metadata( 'user', 0, '_m365_login_oid', '', true ); foreach ( array( '_m365_login_oid', '_m365_login_last_login', '_m365_login_synced', '_m365_login_disabled', '_m365_login_last_sync', '_m365_login_photo' ) as $m365_login_meta_key ) {
delete_metadata( 'user', 0, '_m365_login_last_login', '', true ); delete_metadata( 'user', 0, $m365_login_meta_key, '', true );
}